diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php index 6db4a1c..75d085a 100644 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -709,6 +709,131 @@ class Peptide_News_Admin { echo '

' . esc_html__( 'Widget:', 'peptide-news' ) . ' ' . esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '

'; + // ── Plugin Log Viewer ────────────────────────────────────────── + echo '

' . esc_html__( 'Plugin Log', 'peptide-news' ) . '

'; + echo '

' . esc_html__( 'Recent plugin activity — fetches, AI processing, errors, and admin actions.', 'peptide-news' ) . '

'; + + // Filters row. + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + + // Log table. + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
' . esc_html__( 'Time', 'peptide-news' ) . '' . esc_html__( 'Level', 'peptide-news' ) . '' . esc_html__( 'Context', 'peptide-news' ) . '' . esc_html__( 'Message', 'peptide-news' ) . '
' . esc_html__( 'Loading...', 'peptide-news' ) . '
'; + echo '
'; + + // Pagination. + echo '
'; + + ?> + + '; } diff --git a/includes/class-peptide-news-activator.php b/includes/class-peptide-news-activator.php index 67da210..fb7f024 100644 --- a/includes/class-peptide-news-activator.php +++ b/includes/class-peptide-news-activator.php @@ -15,6 +15,11 @@ class Peptide_News_Activator { self::create_tables(); self::set_default_options(); self::schedule_cron(); + + // Create the log table (idempotent via dbDelta). + if ( class_exists( 'Peptide_News_Logger' ) ) { + Peptide_News_Logger::create_table(); + } } /** diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php index 2f33708..a9f03d1 100644 --- a/includes/class-peptide-news-fetcher.php +++ b/includes/class-peptide-news-fetcher.php @@ -44,11 +44,13 @@ class Peptide_News_Fetcher { // Prevent overlapping fetch jobs. $lock_key = 'peptide_news_fetch_lock'; if ( get_transient( $lock_key ) ) { - $this->log_error( 'Fetch already in progress. Skipping this cycle.' ); + Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' ); return; } set_transient( $lock_key, true, 300 ); // 5-minute lock. + Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' ); + $articles = array(); // Fetch from RSS feeds. @@ -89,6 +91,11 @@ class Peptide_News_Fetcher { 'new_stored' => $stored, ) ); + Peptide_News_Logger::info( sprintf( + 'Fetch complete: %d found, %d filtered out, %d new stored.', + $pre_filter_count, $filtered_out, $stored + ), 'fetch' ); + // Run AI analysis on newly fetched articles (keywords + summary). if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) { $llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php index 80155d9..5aa8804 100644 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -75,14 +75,15 @@ class Peptide_News_LLM { if ( ! is_wp_error( $keywords_result ) ) { $results['keywords'] = self::sanitize_keywords( $keywords_result ); $any_success = true; + Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' ); } else { $err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message(); $results['errors'][] = $err_msg; - self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() ); + Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' ); } } elseif ( $need_keywords ) { $results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model; - self::log_error( 'Invalid keywords model ID: ' . $keywords_model ); + Peptide_News_Logger::error( 'Invalid keywords model ID: ' . $keywords_model, 'llm' ); } // --- Summarization --- @@ -97,14 +98,15 @@ class Peptide_News_LLM { // Strip all HTML tags from LLM output to prevent XSS. $results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) ); $any_success = true; + Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' ); } else { $err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message(); $results['errors'][] = $err_msg; - self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() ); + Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' ); } } elseif ( $need_summary ) { $results['errors'][] = 'Invalid summary model ID: ' . $summary_model; - self::log_error( 'Invalid summary model ID: ' . $summary_model ); + Peptide_News_Logger::error( 'Invalid summary model ID: ' . $summary_model, 'llm' ); } $results['success'] = $any_success; @@ -198,6 +200,12 @@ class Peptide_News_LLM { 'errors' => $last_errors, ) ); + if ( $processed > 0 ) { + Peptide_News_Logger::info( sprintf( 'AI batch complete: %d/%d articles processed.', $processed, count( $articles ) ), 'llm' ); + } elseif ( count( $articles ) > 0 ) { + Peptide_News_Logger::warning( sprintf( 'AI batch: 0/%d articles succeeded.', count( $articles ) ), 'llm' ); + } + return $processed; } @@ -407,6 +415,8 @@ class Peptide_News_LLM { wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' ); } + Peptide_News_Logger::info( 'Bulk AI summary generation triggered by admin.', 'llm' ); + // Auto-correct known broken model names to a working free model. $broken_models = array( 'qwen/qwen3.6-plus:free', diff --git a/includes/class-peptide-news-logger.php b/includes/class-peptide-news-logger.php new file mode 100644 index 0000000..83dcd1d --- /dev/null +++ b/includes/class-peptide-news-logger.php @@ -0,0 +1,254 @@ +prefix . 'peptide_news_log'; + } + + /* ------------------------------------------------------------------ + * Writing + * ----------------------------------------------------------------*/ + + /** + * Write a log entry. + * + * @param string $level One of the LEVEL_* constants. + * @param string $message Human-readable message. + * @param string $context Category tag (fetch, llm, admin, cron, general). + */ + public static function log( $level, $message, $context = 'general' ) { + global $wpdb; + + $wpdb->insert( + self::table(), + array( + 'level' => $level, + 'context' => $context, + 'message' => $message, + 'created_at' => current_time( 'mysql' ), + ), + array( '%s', '%s', '%s', '%s' ) + ); + + // Auto-prune old entries. + self::maybe_prune(); + } + + /** Convenience: info level. */ + public static function info( $message, $context = 'general' ) { + self::log( self::LEVEL_INFO, $message, $context ); + } + + /** Convenience: warning level. */ + public static function warning( $message, $context = 'general' ) { + self::log( self::LEVEL_WARNING, $message, $context ); + } + + /** Convenience: error level. */ + public static function error( $message, $context = 'general' ) { + self::log( self::LEVEL_ERROR, $message, $context ); + } + + /** Convenience: debug level (only writes when WP_DEBUG is on). */ + public static function debug( $message, $context = 'general' ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + self::log( self::LEVEL_DEBUG, $message, $context ); + } + } + + /* ------------------------------------------------------------------ + * Reading + * ----------------------------------------------------------------*/ + + /** + * Retrieve log entries. + * + * @param array $args { + * Optional. Query arguments. + * + * @type string $level Filter by level. + * @type string $context Filter by context. + * @type int $limit Number of rows. + * @type int $offset Pagination offset. + * } + * @return array { 'rows' => array, 'total' => int } + */ + public static function get_logs( $args = array() ) { + global $wpdb; + + $defaults = array( + 'level' => '', + 'context' => '', + 'limit' => 50, + 'offset' => 0, + ); + $args = wp_parse_args( $args, $defaults ); + + $table = self::table(); + $where = array( '1=1' ); + $vals = array(); + + if ( ! empty( $args['level'] ) ) { + $where[] = 'level = %s'; + $vals[] = $args['level']; + } + if ( ! empty( $args['context'] ) ) { + $where[] = 'context = %s'; + $vals[] = $args['context']; + } + + $where_sql = implode( ' AND ', $where ); + + // Total count. + $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; + $total = empty( $vals ) + ? (int) $wpdb->get_var( $count_sql ) + : (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) ); + + // Rows. + $order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d"; + $vals[] = absint( $args['limit'] ); + $vals[] = absint( $args['offset'] ); + $rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) ); + + return array( + 'rows' => $rows ? $rows : array(), + 'total' => $total, + ); + } + + /* ------------------------------------------------------------------ + * Maintenance + * ----------------------------------------------------------------*/ + + /** + * Remove rows beyond the MAX_ROWS cap. + */ + private static function maybe_prune() { + global $wpdb; + $table = self::table(); + + // Only prune every ~50 writes (probabilistic). + if ( wp_rand( 1, 50 ) !== 1 ) { + return; + } + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); + + if ( $count > self::MAX_ROWS ) { + $delete_count = $count - self::MAX_ROWS; + $wpdb->query( $wpdb->prepare( + "DELETE FROM {$table} ORDER BY id ASC LIMIT %d", + $delete_count + ) ); + } + } + + /** + * Clear all log entries. + */ + public static function clear() { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE " . self::table() ); + } + + /* ------------------------------------------------------------------ + * AJAX handlers + * ----------------------------------------------------------------*/ + + /** + * AJAX: fetch paginated log entries. + */ + public static function ajax_get_logs() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; + $level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : ''; + $context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : ''; + $per_page = 50; + + $result = self::get_logs( array( + 'level' => $level, + 'context' => $context, + 'limit' => $per_page, + 'offset' => ( $page - 1 ) * $per_page, + ) ); + + wp_send_json_success( array( + 'rows' => $result['rows'], + 'total' => $result['total'], + 'page' => $page, + 'total_pages' => ceil( $result['total'] / $per_page ), + ) ); + } + + /** + * AJAX: clear all log entries. + */ + public static function ajax_clear_logs() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + self::clear(); + self::info( 'Log cleared by admin.', 'admin' ); + + wp_send_json_success( array( 'message' => 'Log cleared.' ) ); + } + + /* ------------------------------------------------------------------ + * Schema + * ----------------------------------------------------------------*/ + + /** + * Create the log table (called from activator / upgrade check). + */ + public static function create_table() { + global $wpdb; + + $table = self::table(); + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + level VARCHAR(10) NOT NULL DEFAULT 'info', + context VARCHAR(30) NOT NULL DEFAULT 'general', + message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_level (level), + KEY idx_context (context), + KEY idx_created (created_at) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } +} diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 25a0cc3..669cbb0 100644 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -39,6 +39,7 @@ class Peptide_News { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php'; @@ -82,6 +83,8 @@ class Peptide_News { $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' ); + $this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' ); + $this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' ); } /** diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php index 7b5aeaf..7598d44 100644 --- a/peptide-news-plugin.php +++ b/peptide-news-plugin.php @@ -3,7 +3,7 @@ * Plugin Name: Peptide News Aggregator * Plugin URI: https://github.com/peptiderepo/peptide-news-plugin * Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting. - * Version: 2.0.1 + * Version: 2.1.0 * Author: Peptide News Team * Author URI: https://github.com/peptiderepo * License: GPL-2.0+ @@ -22,7 +22,7 @@ if ( ! defined( 'WPINC' ) ) { /** * Current plugin version — follows SemVer. */ -define( 'PEPTIDE_NEWS_VERSION', '2.0.1' ); +define( 'PEPTIDE_NEWS_VERSION', '2.1.0' ); define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); @@ -60,6 +60,7 @@ function peptide_news_check_db_upgrade() { $installed_version = get_option( 'peptide_news_db_version', '1.0.0' ); if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php'; Peptide_News_Activator::activate(); } } diff --git a/peptide-news-plugin/admin/class-peptide-news-admin.php b/peptide-news-plugin/admin/class-peptide-news-admin.php index 6db4a1c..75d085a 100644 --- a/peptide-news-plugin/admin/class-peptide-news-admin.php +++ b/peptide-news-plugin/admin/class-peptide-news-admin.php @@ -709,6 +709,131 @@ class Peptide_News_Admin { echo '

' . esc_html__( 'Widget:', 'peptide-news' ) . ' ' . esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '

'; + // ── Plugin Log Viewer ────────────────────────────────────────── + echo '

' . esc_html__( 'Plugin Log', 'peptide-news' ) . '

'; + echo '

' . esc_html__( 'Recent plugin activity — fetches, AI processing, errors, and admin actions.', 'peptide-news' ) . '

'; + + // Filters row. + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + + // Log table. + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
' . esc_html__( 'Time', 'peptide-news' ) . '' . esc_html__( 'Level', 'peptide-news' ) . '' . esc_html__( 'Context', 'peptide-news' ) . '' . esc_html__( 'Message', 'peptide-news' ) . '
' . esc_html__( 'Loading...', 'peptide-news' ) . '
'; + echo '
'; + + // Pagination. + echo '
'; + + ?> + + '; } diff --git a/peptide-news-plugin/includes/class-peptide-news-activator.php b/peptide-news-plugin/includes/class-peptide-news-activator.php index 5562ba8..fb7f024 100644 --- a/peptide-news-plugin/includes/class-peptide-news-activator.php +++ b/peptide-news-plugin/includes/class-peptide-news-activator.php @@ -2,36 +2,146 @@ /** * Fired during plugin activation. * - * Create database tables and dispatch activation hooks. + * Creates database tables and schedules the cron event. * * @since 1.0.0 */ - class Peptide_News_Activator { + /** + * Create custom tables and schedule cron. + */ public static function activate() { - global $wpdb; + self::create_tables(); + self::set_default_options(); + self::schedule_cron(); - // Create tables. - require_once abspath(__FILE__, '../includes/class-peptide-news-activator.php'); + // Create the log table (idempotent via dbDelta). + if ( class_exists( 'Peptide_News_Logger' ) ) { + Peptide_News_Logger::create_table(); + } + } + + /** + * Create the articles and analytics database tables. + */ + private static function create_tables() { + global $wpdb; $charset_collate = $wpdb->get_charset_collate(); - // Create articles table. - $wpdb->query( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}peptide_news_articles ( - ID bigint(19) UNSIGNED AUTO_INCREMENT, - title varchar(255), - excerpt LONGTEXT, - content LONGTEXT, - source varchar(255), - author varchar(255), - thumbnail varchar(500), - source_url text, - category_id bigint(20), - published_at datetime, - fetched_at datetime, - PRIMARY KEY (ID) - ){ $upload_file_name}{" WITH PARTITION (TITLE, STTUS; - * INTEGER ", DROP TIE VABLOCKED IF EXISTS"} WITH (TEMP=da2) "); + // Articles table — stores fetched news data for analysis. + $articles_table = $wpdb->prefix . 'peptide_news_articles'; + $sql_articles = "CREATE TABLE {$articles_table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + source VARCHAR(100) NOT NULL DEFAULT '', + source_url VARCHAR(2048) NOT NULL DEFAULT '', + title TEXT NOT NULL, + excerpt TEXT NOT NULL, + content LONGTEXT NOT NULL, + author VARCHAR(255) NOT NULL DEFAULT '', + thumbnail_url VARCHAR(2048) NOT NULL DEFAULT '', + thumbnail_local VARCHAR(512) NOT NULL DEFAULT '', + published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + categories VARCHAR(500) NOT NULL DEFAULT '', + tags VARCHAR(500) NOT NULL DEFAULT '', + language VARCHAR(10) NOT NULL DEFAULT 'en', + sentiment_score FLOAT DEFAULT NULL, + ai_summary TEXT NOT NULL, + hash VARCHAR(64) NOT NULL DEFAULT '', + is_active TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY hash (hash), + KEY idx_published (published_at), + KEY idx_source (source), + KEY idx_active_published (is_active, published_at) + ) {$charset_collate};"; + + // Click analytics table — one row per click event. + $clicks_table = $wpdb->prefix . 'peptide_news_clicks'; + $sql_clicks = "CREATE TABLE {$clicks_table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + article_id BIGINT(20) UNSIGNED NOT NULL, + clicked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_ip VARCHAR(45) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL, + referrer_url VARCHAR(2048) NOT NULL DEFAULT '', + page_url VARCHAR(2048) NOT NULL DEFAULT '', + session_id VARCHAR(64) NOT NULL DEFAULT '', + user_id BIGINT(20) UNSIGNED DEFAULT NULL, + country VARCHAR(2) NOT NULL DEFAULT '', + device_type VARCHAR(20) NOT NULL DEFAULT '', + PRIMARY KEY (id), + KEY idx_article (article_id), + KEY idx_clicked_at (clicked_at), + KEY idx_session (session_id), + KEY idx_article_date (article_id, clicked_at) + ) {$charset_collate};"; + + // Daily aggregates table — for fast trend queries. + $daily_table = $wpdb->prefix . 'peptide_news_daily_stats'; + $sql_daily = "CREATE TABLE {$daily_table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + article_id BIGINT(20) UNSIGNED NOT NULL, + stat_date DATE NOT NULL, + click_count INT UNSIGNED NOT NULL DEFAULT 0, + unique_visitors INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY idx_article_date (article_id, stat_date), + KEY idx_date (stat_date) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + dbDelta( $sql_articles ); + dbDelta( $sql_clicks ); + dbDelta( $sql_daily ); + + update_option( 'peptide_news_db_version', PEPTIDE_NEWS_VERSION ); + } + + /** + * Set sensible default option values. + */ + private static function set_default_options() { + $defaults = array( + 'fetch_interval' => 'twicedaily', + 'articles_count' => 10, + 'newsapi_key' => '', + 'newsapi_enabled' => 0, + 'rss_feeds' => implode( "\n", array( + 'https://news.google.com/rss/search?q=peptide+research&hl=en-US&gl=US&ceid=US:en', + 'https://pubmed.ncbi.nlm.nih.gov/rss/search/1sUlVGjEXm1YN9QiGPblv_-bwB51JCtyLwVX85CVGDKCHFFR9Q/?limit=20&utm_campaign=pubmed-2&fc=20220101000000', + ) ), + 'rss_enabled' => 1, + 'search_keywords' => 'peptide, peptides, peptide therapy, peptide research, BPC-157, Thymosin, GHK-Cu', + 'thumbnail_fallback' => PEPTIDE_NEWS_PLUGIN_URL . 'public/images/default-thumb.png', + 'article_retention' => 90, + 'analytics_retention' => 365, + 'anonymize_ip' => 1, + 'llm_enabled' => 0, + 'openrouter_api_key' => '', + 'llm_keywords_model' => 'google/gemini-2.0-flash-001', + 'llm_summary_model' => 'google/gemini-2.0-flash-001', + 'llm_max_articles' => 10, + ); + + foreach ( $defaults as $key => $value ) { + if ( false === get_option( "peptide_news_{$key}" ) ) { + add_option( "peptide_news_{$key}", $value ); + } + } + } + + /** + * Schedule the cron event based on the configured interval. + */ + private static function schedule_cron() { + $interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); + + if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) { + wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' ); + } } } diff --git a/peptide-news-plugin/includes/class-peptide-news-fetcher.php b/peptide-news-plugin/includes/class-peptide-news-fetcher.php index 1bb1d54..a9f03d1 100644 --- a/peptide-news-plugin/includes/class-peptide-news-fetcher.php +++ b/peptide-news-plugin/includes/class-peptide-news-fetcher.php @@ -1,15 +1,544 @@ 900, + 'display' => __( 'Every 15 Minutes', 'peptide-news' ), + ); + $schedules['every_thirty_minutes'] = array( + 'interval' => 1800, + 'display' => __( 'Every 30 Minutes', 'peptide-news' ), + ); + $schedules['every_four_hours'] = array( + 'interval' => 14400, + 'display' => __( 'Every 4 Hours', 'peptide-news' ), + ); + $schedules['every_six_hours'] = array( + 'interval' => 21600, + 'display' => __( 'Every 6 Hours', 'peptide-news' ), + ); + return $schedules; + } + + /** + * Master fetch method — called by WP-Cron. + * Uses a transient lock to prevent concurrent executions. + */ + public function fetch_all_sources() { + // Prevent overlapping fetch jobs. + $lock_key = 'peptide_news_fetch_lock'; + if ( get_transient( $lock_key ) ) { + Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' ); + return; + } + set_transient( $lock_key, true, 300 ); // 5-minute lock. + + Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' ); + + $articles = array(); + + // Fetch from RSS feeds. + if ( get_option( 'peptide_news_rss_enabled', 1 ) ) { + $rss_articles = $this->fetch_rss_feeds(); + $articles = array_merge( $articles, $rss_articles ); + } + + // Fetch from NewsAPI. + if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) { + $api_key = get_option( 'peptide_news_newsapi_key', '' ); + if ( ! empty( $api_key ) ) { + $newsapi_articles = $this->fetch_newsapi( $api_key ); + $articles = array_merge( $articles, $newsapi_articles ); + } + } + + // Filter out ads, press releases, and promotional content. + $pre_filter_count = count( $articles ); + if ( class_exists( 'Peptide_News_Content_Filter' ) && Peptide_News_Content_Filter::is_enabled() ) { + $articles = Peptide_News_Content_Filter::filter_articles( $articles ); + } + $filtered_out = $pre_filter_count - count( $articles ); + + // Store articles (deduplication handled by hash). + $stored = 0; + foreach ( $articles as $article ) { + if ( $this->store_article( $article ) ) { + $stored++; + } + } + + // Log the fetch result. + update_option( 'peptide_news_last_fetch', array( + 'time' => current_time( 'mysql' ), + 'found' => $pre_filter_count, + 'filtered_out' => $filtered_out, + 'new_stored' => $stored, + ) ); + + Peptide_News_Logger::info( sprintf( + 'Fetch complete: %d found, %d filtered out, %d new stored.', + $pre_filter_count, $filtered_out, $stored + ), 'fetch' ); + + // Run AI analysis on newly fetched articles (keywords + summary). + if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) { + $llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); + $llm_processed = Peptide_News_LLM::process_unanalyzed( $llm_batch_size ); + + // Append LLM stats to the fetch log. + $fetch_log = get_option( 'peptide_news_last_fetch' ); + if ( is_array( $fetch_log ) ) { + $fetch_log['ai_processed'] = $llm_processed; + update_option( 'peptide_news_last_fetch', $fetch_log ); + } + } + + // Prune old articles beyond retention period. + $this->prune_old_articles(); + + // Release the fetch lock. + delete_transient( $lock_key ); + } + + /** + * Fetch articles from configured RSS feeds. + * + * @return array + */ + private function fetch_rss_feeds() { + $feeds_raw = get_option( 'peptide_news_rss_feeds', '' ); + $feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) ); + $articles = array(); + + if ( ! function_exists( 'fetch_feed' ) ) { + require_once ABSPATH . WPINC . '/feed.php'; + } + + foreach ( $feeds as $feed_url ) { + $feed = fetch_feed( esc_url_raw( $feed_url ) ); + + if ( is_wp_error( $feed ) ) { + $this->log_error( 'RSS fetch failed for ' . $feed_url . ': ' . $feed->get_error_message() ); + continue; + } + + $max_items = $feed->get_item_quantity( 50 ); + $items = $feed->get_items( 0, $max_items ); + + foreach ( $items as $item ) { + $pub_date = $item->get_date( 'Y-m-d H:i:s' ); + if ( empty( $pub_date ) ) { + $pub_date = current_time( 'mysql' ); + } + + $article_url = esc_url_raw( $item->get_permalink() ); + $source_name = $this->resolve_article_source( $item, $feed_url, $article_url ); + + $articles[] = array( + 'source' => $source_name, + 'source_url' => $article_url, + 'title' => sanitize_text_field( $item->get_title() ), + 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ), + 'content' => wp_kses_post( $item->get_content() ), + 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ), + 'thumbnail_url' => '', + 'published_at' => $pub_date, + 'categories' => $this->extract_categories( $item ), + 'tags' => '', + 'language' => 'en', + ); + } + } + + return $articles; + } + + /** + * Fetch articles from NewsAPI.org. + * + * @param string $api_key + * @return array + */ + private function fetch_newsapi( $api_key ) { + $keywords = get_option( 'peptide_news_search_keywords', 'peptide research' ); + $articles = array(); + + $url = add_query_arg( array( + 'q' => urlencode( $keywords ), + 'language' => 'en', + 'sortBy' => 'publishedAt', + 'pageSize' => 50, + 'apiKey' => $api_key, + ), 'https://newsapi.org/v2/everything' ); + + $response = wp_remote_get( $url, array( + 'timeout' => 30, + 'headers' => array( 'User-Agent' => 'PeptideNewsWP/' . PEPTIDE_NEWS_VERSION ), + ) ); + + if ( is_wp_error( $response ) ) { + $this->log_error( 'NewsAPI fetch failed: ' . $response->get_error_message() ); + return $articles; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( empty( $body['articles'] ) || 'ok' !== ( $body['status'] ?? '' ) ) { + $this->log_error( 'NewsAPI returned no articles or error status.' ); + return $articles; + } + + foreach ( $body['articles'] as $item ) { + $pub_date = isset( $item['publishedAt'] ) + ? gmdate( 'Y-m-d H:i:s', strtotime( $item['publishedAt'] ) ) + : current_time( 'mysql' ); + + $articles[] = array( + 'source' => sanitize_text_field( $item['source']['name'] ?? 'NewsAPI' ), + 'source_url' => esc_url_raw( $item['url'] ?? '' ), + 'title' => sanitize_text_field( $item['title'] ?? '' ), + 'excerpt' => wp_trim_words( sanitize_text_field( $item['description'] ?? '' ), 40 ), + 'content' => wp_kses_post( $item['content'] ?? '' ), + 'author' => sanitize_text_field( $item['author'] ?? '' ), + 'thumbnail_url' => '', + 'published_at' => $pub_date, + 'categories' => '', + 'tags' => '', + 'language' => 'en', + ); + } + + return $articles; + } + + /** + * Store an article in the database. Deduplication via SHA-256 hash of URL. + * + * @param array $article + * @return bool True if new article was inserted. + */ + private function store_article( $article ) { global $wpdb; - // Fetch from NewsAP \ No newline at end of file + $table = $wpdb->prefix . 'peptide_news_articles'; + $hash = hash( 'sha256', $article['source_url'] ); + + // Check for duplicate. + $exists = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$table} WHERE hash = %s", + $hash + ) ); + + if ( $exists ) { + return false; + } + + $result = $wpdb->insert( + $table, + array( + 'source' => $article['source'], + 'source_url' => $article['source_url'], + 'title' => $article['title'], + 'excerpt' => $article['excerpt'], + 'content' => $article['content'], + 'author' => $article['author'], + 'thumbnail_url' => $article['thumbnail_url'], + 'published_at' => $article['published_at'], + 'fetched_at' => current_time( 'mysql' ), + 'categories' => $article['categories'], + 'tags' => $article['tags'], + 'language' => $article['language'], + 'hash' => $hash, + 'is_active' => 1, + ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' ) + ); + + return false !== $result; + } + + /** + * Remove articles older than the retention period. + */ + private function prune_old_articles() { + global $wpdb; + + $retention_days = (int) get_option( 'peptide_news_article_retention', 90 ); + $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) ); + + $table = $wpdb->prefix . 'peptide_news_articles'; + $wpdb->query( $wpdb->prepare( + "UPDATE {$table} SET is_active = 0 WHERE published_at < %s", + $cutoff + ) ); + } + + /** + * Clear all article transient caches. + */ + private function clear_article_cache() { + global $wpdb; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} + WHERE option_name LIKE %s + OR option_name LIKE %s", + $wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%', + $wpdb->esc_like( '_transient_timeout_peptide_news_articles_' ) . '%' + ) + ); + } + + /** + * Extract categories from a SimplePie item. + * + * @param SimplePie_Item $item + * @return string Comma-separated categories. + */ + private function extract_categories( $item ) { + $categories = $item->get_categories(); + if ( empty( $categories ) ) { + return ''; + } + + $names = array(); + foreach ( $categories as $cat ) { + $names[] = sanitize_text_field( $cat->get_label() ); + } + + return implode( ', ', array_filter( $names ) ); + } + + /** + * Resolve the real source name for an RSS feed item. + * + * Google News RSS wraps articles from other publishers behind + * news.google.com URLs, so the feed-level domain is useless. + * This method tries several strategies in priority order: + * + * 1. SimplePie element (Google News RSS includes this). + * 2. Title suffix — Google News formats titles as "Headline - Source". + * 3. Resolve the redirect URL to get the actual publisher domain. + * 4. Fall back to the article permalink domain. + * 5. Last resort: feed URL domain. + * + * @param SimplePie_Item $item The RSS item. + * @param string $feed_url The feed URL. + * @param string $article_url The article permalink. + * @return string Source name or domain. + */ + private function resolve_article_source( $item, $feed_url, $article_url ) { + $feed_domain = $this->extract_domain( $feed_url ); + $is_aggregator = in_array( + $feed_domain, + array( 'news.google.com', 'news.yahoo.com', 'msn.com', 'feedly.com' ), + true + ); + + // Strategy 1: SimplePie element. + $source_obj = $item->get_source(); + if ( $source_obj ) { + $source_title = $source_obj->get_title(); + if ( ! empty( $source_title ) ) { + return sanitize_text_field( $source_title ); + } + $source_link = $source_obj->get_link(); + if ( ! empty( $source_link ) ) { + $domain = $this->extract_domain( $source_link ); + if ( 'unknown' !== $domain ) { + return $domain; + } + } + } + + // Strategy 2: Parse "Headline - Source" from title (common in aggregator feeds). + if ( $is_aggregator ) { + $title = $item->get_title(); + if ( ! empty( $title ) && preg_match( '/\s[-\x{2013}\x{2014}]\s([^-\x{2013}\x{2014}]+)$/u', $title, $matches ) ) { + $candidate = trim( $matches[1] ); + if ( mb_strlen( $candidate ) <= 60 && mb_strlen( $candidate ) >= 2 ) { + return sanitize_text_field( $candidate ); + } + } + } + + // Strategy 3: Resolve Google News redirect URL to actual destination. + if ( $is_aggregator ) { + $resolved = $this->resolve_redirect_url( $article_url ); + if ( $resolved && $resolved !== $article_url ) { + $domain = $this->extract_domain( $resolved ); + if ( 'unknown' !== $domain && $domain !== $feed_domain ) { + return $domain; + } + } + } + + // Strategy 4: Use the article permalink domain. + $article_domain = $this->extract_domain( $article_url ); + if ( 'unknown' !== $article_domain && $article_domain !== $feed_domain ) { + return $article_domain; + } + + // Strategy 5: Fall back to the feed domain. + return $feed_domain; + } + + /** + * Resolve a redirect URL to its final destination without downloading the body. + * + * @param string $url The URL to resolve. + * @return string|false The final URL or false on failure. + */ + private function resolve_redirect_url( $url ) { + if ( empty( $url ) ) { + return false; + } + + $response = wp_remote_head( $url, array( + 'timeout' => 8, + 'redirection' => 5, + 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)', + ) ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $final_url = wp_remote_retrieve_header( $response, 'location' ); + + if ( empty( $final_url ) && isset( $response['http_response'] ) ) { + $http_response = $response['http_response']; + if ( method_exists( $http_response, 'get_response_object' ) ) { + $raw = $http_response->get_response_object(); + if ( isset( $raw->url ) ) { + return $raw->url; + } + } + } + + return ! empty( $final_url ) ? $final_url : false; + } + + /** + * Backfill source names for existing articles that show an aggregator domain. + * + * Parses the " - Source" suffix from stored titles to update the source column. + * + * @return int Number of articles updated. + */ + public function backfill_article_sources() { + global $wpdb; + + $table = $wpdb->prefix . 'peptide_news_articles'; + $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' ); + $placeholders = implode( ', ', array_fill( 0, count( $aggregators ), '%s' ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $articles = $wpdb->get_results( $wpdb->prepare( + "SELECT id, title, source, source_url + FROM {$table} + WHERE source IN ({$placeholders}) + AND is_active = 1", + ...$aggregators + ) ); + + if ( empty( $articles ) ) { + return 0; + } + + $updated = 0; + + foreach ( $articles as $article ) { + $new_source = ''; + + // Try parsing "Headline - Source" from title. + if ( preg_match( '/\s[-\x{2013}\x{2014}]\s([^-\x{2013}\x{2014}]+)$/u', $article->title, $matches ) ) { + $candidate = trim( $matches[1] ); + if ( mb_strlen( $candidate ) <= 60 && mb_strlen( $candidate ) >= 2 ) { + $new_source = sanitize_text_field( $candidate ); + } + } + + // Fall back to resolving the redirect URL. + if ( empty( $new_source ) ) { + $resolved = $this->resolve_redirect_url( $article->source_url ); + if ( $resolved && $resolved !== $article->source_url ) { + $domain = $this->extract_domain( $resolved ); + if ( 'unknown' !== $domain && ! in_array( $domain, $aggregators, true ) ) { + $new_source = $domain; + } + } + usleep( 300000 ); // 0.3s throttle for HTTP requests. + } + + if ( ! empty( $new_source ) && $new_source !== $article->source ) { + $wpdb->update( + $table, + array( 'source' => $new_source ), + array( 'id' => $article->id ), + array( '%s' ), + array( '%d' ) + ); + $updated++; + } + } + + if ( $updated > 0 ) { + $this->clear_article_cache(); + } + + return $updated; + } + + /** + * AJAX handler for the backfill action. + */ + public function ajax_backfill_sources() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + $updated = $this->backfill_article_sources(); + wp_send_json_success( array( 'updated' => $updated ) ); + } + + /** + * Extract domain from a URL for source identification. + * + * @param string $url + * @return string + */ + private function extract_domain( $url ) { + $host = wp_parse_url( $url, PHP_URL_HOST ); + return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown'; + } + + /** + * Log an error to the WordPress debug log. + * + * @param string $message + */ + private function log_error( $message ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( '[Peptide News] ' . $message ); + } + } +} diff --git a/peptide-news-plugin/includes/class-peptide-news-llm.php b/peptide-news-plugin/includes/class-peptide-news-llm.php index 80155d9..5aa8804 100644 --- a/peptide-news-plugin/includes/class-peptide-news-llm.php +++ b/peptide-news-plugin/includes/class-peptide-news-llm.php @@ -75,14 +75,15 @@ class Peptide_News_LLM { if ( ! is_wp_error( $keywords_result ) ) { $results['keywords'] = self::sanitize_keywords( $keywords_result ); $any_success = true; + Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' ); } else { $err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message(); $results['errors'][] = $err_msg; - self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() ); + Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' ); } } elseif ( $need_keywords ) { $results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model; - self::log_error( 'Invalid keywords model ID: ' . $keywords_model ); + Peptide_News_Logger::error( 'Invalid keywords model ID: ' . $keywords_model, 'llm' ); } // --- Summarization --- @@ -97,14 +98,15 @@ class Peptide_News_LLM { // Strip all HTML tags from LLM output to prevent XSS. $results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) ); $any_success = true; + Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' ); } else { $err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message(); $results['errors'][] = $err_msg; - self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() ); + Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' ); } } elseif ( $need_summary ) { $results['errors'][] = 'Invalid summary model ID: ' . $summary_model; - self::log_error( 'Invalid summary model ID: ' . $summary_model ); + Peptide_News_Logger::error( 'Invalid summary model ID: ' . $summary_model, 'llm' ); } $results['success'] = $any_success; @@ -198,6 +200,12 @@ class Peptide_News_LLM { 'errors' => $last_errors, ) ); + if ( $processed > 0 ) { + Peptide_News_Logger::info( sprintf( 'AI batch complete: %d/%d articles processed.', $processed, count( $articles ) ), 'llm' ); + } elseif ( count( $articles ) > 0 ) { + Peptide_News_Logger::warning( sprintf( 'AI batch: 0/%d articles succeeded.', count( $articles ) ), 'llm' ); + } + return $processed; } @@ -407,6 +415,8 @@ class Peptide_News_LLM { wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' ); } + Peptide_News_Logger::info( 'Bulk AI summary generation triggered by admin.', 'llm' ); + // Auto-correct known broken model names to a working free model. $broken_models = array( 'qwen/qwen3.6-plus:free', diff --git a/peptide-news-plugin/includes/class-peptide-news-logger.php b/peptide-news-plugin/includes/class-peptide-news-logger.php new file mode 100644 index 0000000..83dcd1d --- /dev/null +++ b/peptide-news-plugin/includes/class-peptide-news-logger.php @@ -0,0 +1,254 @@ +prefix . 'peptide_news_log'; + } + + /* ------------------------------------------------------------------ + * Writing + * ----------------------------------------------------------------*/ + + /** + * Write a log entry. + * + * @param string $level One of the LEVEL_* constants. + * @param string $message Human-readable message. + * @param string $context Category tag (fetch, llm, admin, cron, general). + */ + public static function log( $level, $message, $context = 'general' ) { + global $wpdb; + + $wpdb->insert( + self::table(), + array( + 'level' => $level, + 'context' => $context, + 'message' => $message, + 'created_at' => current_time( 'mysql' ), + ), + array( '%s', '%s', '%s', '%s' ) + ); + + // Auto-prune old entries. + self::maybe_prune(); + } + + /** Convenience: info level. */ + public static function info( $message, $context = 'general' ) { + self::log( self::LEVEL_INFO, $message, $context ); + } + + /** Convenience: warning level. */ + public static function warning( $message, $context = 'general' ) { + self::log( self::LEVEL_WARNING, $message, $context ); + } + + /** Convenience: error level. */ + public static function error( $message, $context = 'general' ) { + self::log( self::LEVEL_ERROR, $message, $context ); + } + + /** Convenience: debug level (only writes when WP_DEBUG is on). */ + public static function debug( $message, $context = 'general' ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + self::log( self::LEVEL_DEBUG, $message, $context ); + } + } + + /* ------------------------------------------------------------------ + * Reading + * ----------------------------------------------------------------*/ + + /** + * Retrieve log entries. + * + * @param array $args { + * Optional. Query arguments. + * + * @type string $level Filter by level. + * @type string $context Filter by context. + * @type int $limit Number of rows. + * @type int $offset Pagination offset. + * } + * @return array { 'rows' => array, 'total' => int } + */ + public static function get_logs( $args = array() ) { + global $wpdb; + + $defaults = array( + 'level' => '', + 'context' => '', + 'limit' => 50, + 'offset' => 0, + ); + $args = wp_parse_args( $args, $defaults ); + + $table = self::table(); + $where = array( '1=1' ); + $vals = array(); + + if ( ! empty( $args['level'] ) ) { + $where[] = 'level = %s'; + $vals[] = $args['level']; + } + if ( ! empty( $args['context'] ) ) { + $where[] = 'context = %s'; + $vals[] = $args['context']; + } + + $where_sql = implode( ' AND ', $where ); + + // Total count. + $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; + $total = empty( $vals ) + ? (int) $wpdb->get_var( $count_sql ) + : (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) ); + + // Rows. + $order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d"; + $vals[] = absint( $args['limit'] ); + $vals[] = absint( $args['offset'] ); + $rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) ); + + return array( + 'rows' => $rows ? $rows : array(), + 'total' => $total, + ); + } + + /* ------------------------------------------------------------------ + * Maintenance + * ----------------------------------------------------------------*/ + + /** + * Remove rows beyond the MAX_ROWS cap. + */ + private static function maybe_prune() { + global $wpdb; + $table = self::table(); + + // Only prune every ~50 writes (probabilistic). + if ( wp_rand( 1, 50 ) !== 1 ) { + return; + } + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); + + if ( $count > self::MAX_ROWS ) { + $delete_count = $count - self::MAX_ROWS; + $wpdb->query( $wpdb->prepare( + "DELETE FROM {$table} ORDER BY id ASC LIMIT %d", + $delete_count + ) ); + } + } + + /** + * Clear all log entries. + */ + public static function clear() { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE " . self::table() ); + } + + /* ------------------------------------------------------------------ + * AJAX handlers + * ----------------------------------------------------------------*/ + + /** + * AJAX: fetch paginated log entries. + */ + public static function ajax_get_logs() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; + $level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : ''; + $context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : ''; + $per_page = 50; + + $result = self::get_logs( array( + 'level' => $level, + 'context' => $context, + 'limit' => $per_page, + 'offset' => ( $page - 1 ) * $per_page, + ) ); + + wp_send_json_success( array( + 'rows' => $result['rows'], + 'total' => $result['total'], + 'page' => $page, + 'total_pages' => ceil( $result['total'] / $per_page ), + ) ); + } + + /** + * AJAX: clear all log entries. + */ + public static function ajax_clear_logs() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + self::clear(); + self::info( 'Log cleared by admin.', 'admin' ); + + wp_send_json_success( array( 'message' => 'Log cleared.' ) ); + } + + /* ------------------------------------------------------------------ + * Schema + * ----------------------------------------------------------------*/ + + /** + * Create the log table (called from activator / upgrade check). + */ + public static function create_table() { + global $wpdb; + + $table = self::table(); + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + level VARCHAR(10) NOT NULL DEFAULT 'info', + context VARCHAR(30) NOT NULL DEFAULT 'general', + message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_level (level), + KEY idx_context (context), + KEY idx_created (created_at) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } +} diff --git a/peptide-news-plugin/includes/class-peptide-news.php b/peptide-news-plugin/includes/class-peptide-news.php index 25a0cc3..669cbb0 100644 --- a/peptide-news-plugin/includes/class-peptide-news.php +++ b/peptide-news-plugin/includes/class-peptide-news.php @@ -39,6 +39,7 @@ class Peptide_News { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php'; @@ -82,6 +83,8 @@ class Peptide_News { $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' ); + $this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' ); + $this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' ); } /** diff --git a/peptide-news-plugin/peptide-news-plugin.php b/peptide-news-plugin/peptide-news-plugin.php index 7b5aeaf..7598d44 100644 --- a/peptide-news-plugin/peptide-news-plugin.php +++ b/peptide-news-plugin/peptide-news-plugin.php @@ -3,7 +3,7 @@ * Plugin Name: Peptide News Aggregator * Plugin URI: https://github.com/peptiderepo/peptide-news-plugin * Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting. - * Version: 2.0.1 + * Version: 2.1.0 * Author: Peptide News Team * Author URI: https://github.com/peptiderepo * License: GPL-2.0+ @@ -22,7 +22,7 @@ if ( ! defined( 'WPINC' ) ) { /** * Current plugin version — follows SemVer. */ -define( 'PEPTIDE_NEWS_VERSION', '2.0.1' ); +define( 'PEPTIDE_NEWS_VERSION', '2.1.0' ); define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); @@ -60,6 +60,7 @@ function peptide_news_check_db_upgrade() { $installed_version = get_option( 'peptide_news_db_version', '1.0.0' ); if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php'; Peptide_News_Activator::activate(); } }