From 102104f4dce3ea72ebfb34c8214f2f4b70040788 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 21:22:34 +0000 Subject: [PATCH] Deploy b92dc4b from main --- admin/class-peptide-news-admin.php | 171 +++++- admin/css/admin-style.css | 0 admin/js/admin-script.js | 0 admin/partials/articles-list.php | 2 + admin/partials/cost-dashboard.php | 325 +++++++++++ admin/partials/dashboard.php | 2 + includes/class-peptide-news-activator.php | 19 +- includes/class-peptide-news-analytics.php | 47 +- .../class-peptide-news-content-filter.php | 38 +- includes/class-peptide-news-cost-tracker.php | 550 ++++++++++++++++++ includes/class-peptide-news-deactivator.php | 3 +- includes/class-peptide-news-encryption.php | 174 ++++++ includes/class-peptide-news-fetcher.php | 38 +- includes/class-peptide-news-llm.php | 201 +++++-- includes/class-peptide-news-loader.php | 9 +- includes/class-peptide-news-logger.php | 25 +- includes/class-peptide-news-rest-api.php | 9 +- includes/class-peptide-news.php | 22 +- languages/peptide-news.pot | 0 peptide-news-plugin.php | 14 +- public/class-peptide-news-public.php | 17 +- public/css/public-style.css | 0 public/images/default-thumb.png | Bin public/js/peptide-news-feed.js | 0 public/js/public-script.js | 0 public/react-app/PeptideNewsFeed.jsx | 0 26 files changed, 1503 insertions(+), 163 deletions(-) mode change 100644 => 100755 admin/class-peptide-news-admin.php mode change 100644 => 100755 admin/css/admin-style.css mode change 100644 => 100755 admin/js/admin-script.js mode change 100644 => 100755 admin/partials/articles-list.php create mode 100755 admin/partials/cost-dashboard.php mode change 100644 => 100755 admin/partials/dashboard.php mode change 100644 => 100755 includes/class-peptide-news-activator.php mode change 100644 => 100755 includes/class-peptide-news-analytics.php mode change 100644 => 100755 includes/class-peptide-news-content-filter.php create mode 100755 includes/class-peptide-news-cost-tracker.php mode change 100644 => 100755 includes/class-peptide-news-deactivator.php create mode 100755 includes/class-peptide-news-encryption.php mode change 100644 => 100755 includes/class-peptide-news-fetcher.php mode change 100644 => 100755 includes/class-peptide-news-llm.php mode change 100644 => 100755 includes/class-peptide-news-loader.php mode change 100644 => 100755 includes/class-peptide-news-logger.php mode change 100644 => 100755 includes/class-peptide-news-rest-api.php mode change 100644 => 100755 includes/class-peptide-news.php mode change 100644 => 100755 languages/peptide-news.pot mode change 100644 => 100755 peptide-news-plugin.php mode change 100644 => 100755 public/class-peptide-news-public.php mode change 100644 => 100755 public/css/public-style.css mode change 100644 => 100755 public/images/default-thumb.png mode change 100644 => 100755 public/js/peptide-news-feed.js mode change 100644 => 100755 public/js/public-script.js mode change 100644 => 100755 public/react-app/PeptideNewsFeed.jsx diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php old mode 100644 new mode 100755 index eb7465b..02c14a6 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -1,4 +1,5 @@ plugin_name = $plugin_name; $this->version = $version; } @@ -22,7 +23,7 @@ class Peptide_News_Admin { /** * Enqueue admin CSS. */ - public function enqueue_styles( $hook ) { + public function enqueue_styles( string $hook ): void { if ( strpos( $hook, 'peptide-news' ) === false ) { return; } @@ -38,7 +39,7 @@ class Peptide_News_Admin { /** * Enqueue admin JS. */ - public function enqueue_scripts( $hook ) { + public function enqueue_scripts( string $hook ): void { if ( strpos( $hook, 'peptide-news' ) === false ) { return; } @@ -71,7 +72,7 @@ class Peptide_News_Admin { /** * Add admin menu pages. */ - public function add_admin_menu() { + public function add_admin_menu(): void { // Main menu — Analytics Dashboard. add_menu_page( __( 'Peptide News', 'peptide-news' ), @@ -102,12 +103,22 @@ class Peptide_News_Admin { 'peptide-news-articles', array( $this, 'render_articles_page' ) ); + + // Sub-menu — Cost Dashboard. + add_submenu_page( + 'peptide-news-dashboard', + __( 'LLM Costs', 'peptide-news' ), + __( 'LLM Costs', 'peptide-news' ), + 'manage_options', + 'peptide-news-costs', + array( $this, 'render_cost_dashboard_page' ) + ); } /** * Register all settings. */ - public function register_settings() { + public function register_settings(): void { // --- General section --- add_settings_section( @@ -213,12 +224,43 @@ class Peptide_News_Admin { $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' ); + + // --- Cost Tracking / Budget section --- + add_settings_section( + 'peptide_news_cost_section', + __( 'Cost Tracking & Budget', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Monitor and control LLM API spending. Set a monthly budget to prevent unexpected charges.', 'peptide-news' ) . '

'; + if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) { + $month_spend = Peptide_News_Cost_Tracker::get_current_month_spend(); + $budget = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + printf( + '

%s: $%.4f', + esc_html__( 'This month\'s spend', 'peptide-news' ), + $month_spend + ); + if ( $budget > 0 ) { + printf( + ' / $%.2f (%.1f%%)', + $budget, + ( $month_spend / $budget ) * 100 + ); + } + echo '

'; + } + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'monthly_budget', __( 'Monthly Budget (USD)', 'peptide-news' ), 'render_monthly_budget_field', 'peptide_news_cost_section' ); + $this->add_setting( 'budget_mode', __( 'Budget Enforcement', 'peptide-news' ), 'render_budget_mode_field', 'peptide_news_cost_section' ); + $this->add_setting( 'cost_retention', __( 'Cost Log Retention (days)', 'peptide-news' ), 'render_cost_retention_field', 'peptide_news_cost_section' ); } /** * Helper: register a single setting with a field callback. */ - private function add_setting( $key, $label, $callback, $section ) { + private function add_setting( string $key, string $label, string $callback, string $section ): void { $option_name = "peptide_news_{$key}"; $sanitize_cb = $this->get_sanitize_callback( $key ); @@ -242,7 +284,7 @@ class Peptide_News_Admin { * @param string $key Setting key (without prefix). * @return callable */ - private function get_sanitize_callback( $key ) { + private function get_sanitize_callback( string $key ) { $callbacks = array( 'fetch_interval' => 'sanitize_text_field', 'articles_count' => 'absint', @@ -270,6 +312,16 @@ class Peptide_News_Admin { 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), 'llm_summary_model' => array( $this, 'sanitize_model_id' ), 'llm_max_articles' => 'absint', + 'monthly_budget' => function ( $value ) { + return max( 0.0, (float) $value ); + }, + 'budget_mode' => function ( $value ) { + $valid = array( 'disabled', 'hard_stop', 'warn_only' ); + return in_array( $value, $valid, true ) ? $value : 'disabled'; + }, + 'cost_retention' => function ( $value ) { + return max( 30, absint( $value ) ); + }, ); return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; @@ -281,7 +333,7 @@ class Peptide_News_Admin { * @param string $value Raw input. * @return string Sanitized model ID or default. */ - public function sanitize_model_id( $value ) { + public function sanitize_model_id( string $value ): string { $value = sanitize_text_field( $value ); if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) { add_settings_error( @@ -300,13 +352,17 @@ class Peptide_News_Admin { } /** - * Sanitize API keys. If a masked key is submitted (••••••••XXXX format), - * keep the existing key. Otherwise, store the new full key. + * Sanitize and encrypt API keys before storage. * - * @param string $value Raw input. - * @return string Stored API key (full value or existing). + * If a masked key is submitted (••••••••XXXX format), the existing + * encrypted value is preserved. New plaintext keys are encrypted + * via Peptide_News_Encryption before being written to wp_options. + * + * @param string $value Raw input from the settings form. + * @param string $option_name The wp_options key for this API key. + * @return string Encrypted API key or existing stored value. */ - public function sanitize_api_key( $value, $option_name ) { + public function sanitize_api_key( string $value, string $option_name ): string { $value = sanitize_text_field( $value ); if ( empty( $value ) ) { return ''; @@ -317,19 +373,33 @@ class Peptide_News_Admin { return get_option( $option_name, '' ); } + // Encrypt the new plaintext key before storing. + if ( class_exists( 'Peptide_News_Encryption' ) ) { + return Peptide_News_Encryption::encrypt( $value ); + } + return $value; } /** * Get a masked version of an API key showing only the last 4 characters. * - * @param string $key Full API key. + * Decrypts the stored value first (handles both encrypted and legacy + * plaintext keys transparently). + * + * @param string $key Stored API key (encrypted or plaintext). * @return string Masked key (e.g., ••••••••XXXX). */ - private function mask_api_key( $key ) { + private function mask_api_key( string $key ): string { if ( empty( $key ) ) { return ''; } + + // Decrypt before masking so we show the real key's last 4 chars. + if ( class_exists( 'Peptide_News_Encryption' ) ) { + $key = Peptide_News_Encryption::decrypt( $key ); + } + $len = strlen( $key ); if ( $len <= 4 ) { return str_repeat( '•', $len ); @@ -591,9 +661,57 @@ class Peptide_News_Admin { echo '

' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '

'; } + // --- Cost tracking field renderers --- + + /** + * Render the monthly budget input field. + */ + public function render_monthly_budget_field(): void { + $value = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + printf( + '', + $value + ); + echo '

' . esc_html__( 'Set to 0 to disable budget limits. The plugin will stop making API calls when this amount is reached (if enforcement is enabled).', 'peptide-news' ) . '

'; + } + + /** + * Render the budget enforcement mode selector. + */ + public function render_budget_mode_field(): void { + $value = get_option( 'peptide_news_budget_mode', 'disabled' ); + $modes = array( + 'disabled' => __( 'Disabled — track costs only, no limits', 'peptide-news' ), + 'warn_only' => __( 'Warn only — log alerts at 50%, 80%, 100%', 'peptide-news' ), + 'hard_stop' => __( 'Hard stop — block API calls when budget is reached', 'peptide-news' ), + ); + echo ''; + } + + /** + * Render the cost log retention field. + */ + public function render_cost_retention_field(): void { + $value = absint( get_option( 'peptide_news_cost_retention', 365 ) ); + printf( + '', + $value + ); + echo '

' . esc_html__( 'How long to keep detailed cost records. Minimum 30 days.', 'peptide-news' ) . '

'; + } + // --- Page renderers --- - public function render_settings_page() { + public function render_settings_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } @@ -895,7 +1013,7 @@ class Peptide_News_Admin { echo ''; } - public function render_dashboard_page() { + public function render_dashboard_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } @@ -913,7 +1031,7 @@ class Peptide_News_Admin { /** * Stream a CSV export of click analytics data. */ - private function export_csv() { + private function export_csv(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) ); } @@ -947,10 +1065,25 @@ class Peptide_News_Admin { exit; } - public function render_articles_page() { + public function render_articles_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php'; } + + /** + * Render the LLM cost tracking dashboard page. + * + * Shows monthly budget status, daily cost chart, per-model breakdown, + * and recent API call log. Data is loaded via AJAX for responsiveness. + * + * @since 2.4.0 + */ + public function render_cost_dashboard_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/cost-dashboard.php'; + } } diff --git a/admin/css/admin-style.css b/admin/css/admin-style.css old mode 100644 new mode 100755 diff --git a/admin/js/admin-script.js b/admin/js/admin-script.js old mode 100644 new mode 100755 diff --git a/admin/partials/articles-list.php b/admin/partials/articles-list.php old mode 100644 new mode 100755 index d0c8d6c..c13b872 --- a/admin/partials/articles-list.php +++ b/admin/partials/articles-list.php @@ -5,6 +5,8 @@ * @since 1.0.0 */ +declare( strict_types=1 ); + if ( ! defined( 'ABSPATH' ) ) { exit; } diff --git a/admin/partials/cost-dashboard.php b/admin/partials/cost-dashboard.php new file mode 100755 index 0000000..01746cf --- /dev/null +++ b/admin/partials/cost-dashboard.php @@ -0,0 +1,325 @@ + +
+

+ + +
+ + + +
+ + +
+

+
+

+
+
+ + +
+
+

+

$0.0000

+
+
+

+

0

+
+
+

+

0

+
+
+ + +
+

+ +
+ + +
+ +
+

+ + + + + + + + + + + + +
+
+ + +
+

+ + + + + + + + + + + +
+
+
+ + +
+

+ + + + + + + + + + + + + + + +
+
+
+ + diff --git a/admin/partials/dashboard.php b/admin/partials/dashboard.php old mode 100644 new mode 100755 index b64f797..c79f5ad --- a/admin/partials/dashboard.php +++ b/admin/partials/dashboard.php @@ -5,6 +5,8 @@ * @since 1.0.0 */ +declare( strict_types=1 ); + if ( ! defined( 'ABSPATH' ) ) { exit; } diff --git a/includes/class-peptide-news-activator.php b/includes/class-peptide-news-activator.php old mode 100644 new mode 100755 index 8fd223d..b57ef58 --- a/includes/class-peptide-news-activator.php +++ b/includes/class-peptide-news-activator.php @@ -1,4 +1,5 @@ get_charset_collate(); @@ -107,7 +113,7 @@ class Peptide_News_Activator { /** * Set sensible default option values. */ - private static function set_default_options() { + private static function set_default_options(): void { $defaults = array( 'fetch_interval' => 'twicedaily', 'articles_count' => 10, @@ -128,6 +134,9 @@ class Peptide_News_Activator { 'llm_keywords_model' => 'google/gemini-2.0-flash-001', 'llm_summary_model' => 'google/gemini-2.0-flash-001', 'llm_max_articles' => 10, + 'monthly_budget' => 0, + 'budget_mode' => 'disabled', + 'cost_retention' => 365, ); foreach ( $defaults as $key => $value ) { @@ -140,7 +149,7 @@ class Peptide_News_Activator { /** * Schedule the cron event based on the configured interval. */ - private static function schedule_cron() { + private static function schedule_cron(): void { $interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) { @@ -151,7 +160,7 @@ class Peptide_News_Activator { /** * Add foreign key constraints to tables. */ - private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) { + private static function add_foreign_key_constraints( string $articles_table, string $clicks_table, string $daily_table ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching diff --git a/includes/class-peptide-news-analytics.php b/includes/class-peptide-news-analytics.php old mode 100644 new mode 100755 index 068edd4..a01e7fe --- a/includes/class-peptide-news-analytics.php +++ b/includes/class-peptide-news-analytics.php @@ -1,4 +1,5 @@ prefix . 'peptide_news_clicks'; @@ -61,7 +62,7 @@ class Peptide_News_Analytics { * @param int $article_id * @param string $ip Anonymized IP for unique visitor counting. */ - private static function update_daily_stats( $article_id, $ip ) { + private static function update_daily_stats( int $article_id, string $ip ): void { global $wpdb; $table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -114,7 +115,7 @@ class Peptide_News_Analytics { * @param int $limit * @return array */ - public static function get_top_articles( $start_date, $end_date, $limit = 20 ) { + public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array { global $wpdb; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -143,7 +144,7 @@ class Peptide_News_Analytics { * @param string $end_date * @return array */ - public static function get_click_trends( $start_date, $end_date ) { + public static function get_click_trends( string $start_date, string $end_date ): array { global $wpdb; $table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -167,7 +168,7 @@ class Peptide_News_Analytics { * @param int $limit * @return array */ - public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) { + public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array { global $wpdb; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -221,7 +222,7 @@ class Peptide_News_Analytics { * @param string $end_date * @return array */ - public static function get_device_breakdown( $start_date, $end_date ) { + public static function get_device_breakdown( string $start_date, string $end_date ): array { global $wpdb; $table = $wpdb->prefix . 'peptide_news_clicks'; @@ -244,7 +245,7 @@ class Peptide_News_Analytics { * @param string $end_date * @return array */ - public static function get_source_performance( $start_date, $end_date ) { + public static function get_source_performance( string $start_date, string $end_date ): array { global $wpdb; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -265,27 +266,41 @@ class Peptide_News_Analytics { ) ); } + /** + * Hard cap on CSV export rows to prevent memory exhaustion on large datasets. + * + * @var int + */ + const CSV_EXPORT_MAX_ROWS = 50000; + /** * Export click data as CSV-ready array. * - * @param string $start_date - * @param string $end_date - * @return array + * Capped at CSV_EXPORT_MAX_ROWS to prevent memory exhaustion on sites + * with large click volumes. The cap is applied at the SQL level so + * only the limited result set is ever loaded into PHP memory. + * + * @param string $start_date Y-m-d format. + * @param string $end_date Y-m-d format. + * @return array Array of associative arrays (one per click row). */ - public static function export_clicks_csv( $start_date, $end_date ) { + public static function export_clicks_csv( string $start_date, string $end_date ): array { global $wpdb; $clicks_table = $wpdb->prefix . 'peptide_news_clicks'; $articles_table = $wpdb->prefix . 'peptide_news_articles'; + return $wpdb->get_results( $wpdb->prepare( "SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories, c.referrer_url, c.page_url, c.device_type, c.session_id FROM {$clicks_table} c INNER JOIN {$articles_table} a ON a.id = c.article_id WHERE DATE(c.clicked_at) BETWEEN %s AND %s - ORDER BY c.clicked_at DESC", + ORDER BY c.clicked_at DESC + LIMIT %d", $start_date, - $end_date + $end_date, + self::CSV_EXPORT_MAX_ROWS ), ARRAY_A ); } @@ -294,7 +309,7 @@ class Peptide_News_Analytics { * * @return string|null */ - private static function get_client_ip() { + private static function get_client_ip(): ?string { $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); foreach ( $ip_keys as $key ) { @@ -316,7 +331,7 @@ class Peptide_News_Analytics { * @param string $ip * @return string */ - private static function anonymize_ip( $ip ) { + private static function anonymize_ip( string $ip ): string { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return preg_replace( '/\.\d+$/', '.0', $ip ); } @@ -332,7 +347,7 @@ class Peptide_News_Analytics { * @param string $ua * @return string */ - private static function detect_device( $ua ) { + private static function detect_device( string $ua ): string { $ua = strtolower( $ua ); if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { diff --git a/includes/class-peptide-news-content-filter.php b/includes/class-peptide-news-content-filter.php old mode 100644 new mode 100755 index 5cb2910..3ac6431 --- a/includes/class-peptide-news-content-filter.php +++ b/includes/class-peptide-news-content-filter.php @@ -1,4 +1,5 @@ 1, 'moderate' => 2, @@ -435,7 +439,7 @@ class Peptide_News_Content_Filter { * * @return array */ - public static function get_title_keywords() { + public static function get_title_keywords(): array { $custom = get_option( 'peptide_news_filter_title_keywords', '' ); if ( ! empty( $custom ) ) { @@ -450,7 +454,7 @@ class Peptide_News_Content_Filter { * * @return array */ - public static function get_body_keywords() { + public static function get_body_keywords(): array { $custom = get_option( 'peptide_news_filter_body_keywords', '' ); if ( ! empty( $custom ) ) { @@ -465,7 +469,7 @@ class Peptide_News_Content_Filter { * * @return array */ - public static function get_blocked_domains() { + public static function get_blocked_domains(): array { $custom = get_option( 'peptide_news_filter_blocked_domains', '' ); if ( ! empty( $custom ) ) { @@ -480,7 +484,7 @@ class Peptide_News_Content_Filter { * * @return string */ - public static function get_default_title_keywords_text() { + public static function get_default_title_keywords_text(): string { return implode( "\n", self::$default_title_keywords ); } @@ -489,7 +493,7 @@ class Peptide_News_Content_Filter { * * @return string */ - public static function get_default_body_keywords_text() { + public static function get_default_body_keywords_text(): string { return implode( "\n", self::$default_body_keywords ); } @@ -498,7 +502,7 @@ class Peptide_News_Content_Filter { * * @return string */ - public static function get_default_blocked_domains_text() { + public static function get_default_blocked_domains_text(): string { return implode( "\n", self::$default_blocked_domains ); } @@ -509,7 +513,7 @@ class Peptide_News_Content_Filter { * @param int $removed Articles removed by the filter. * @param int $llm_checked Articles sent to LLM for classification. */ - private static function update_filter_stats( $total, $removed, $llm_checked ) { + private static function update_filter_stats( int $total, int $removed, int $llm_checked ): void { update_option( 'peptide_news_filter_last_run', array( 'time' => current_time( 'mysql' ), 'total' => $total, @@ -524,7 +528,7 @@ class Peptide_News_Content_Filter { * * @param string $message Log message. */ - private static function log( $message ) { + private static function log( string $message ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( self::LOG_PREFIX . ' ' . $message ); } diff --git a/includes/class-peptide-news-cost-tracker.php b/includes/class-peptide-news-cost-tracker.php new file mode 100755 index 0000000..a4f725e --- /dev/null +++ b/includes/class-peptide-news-cost-tracker.php @@ -0,0 +1,550 @@ + + */ + const DEFAULT_MODEL_PRICING = array( + 'google/gemini-2.0-flash-001' => array( 'input' => 0.10, 'output' => 0.40 ), + 'google/gemma-3-27b-it:free' => array( 'input' => 0.00, 'output' => 0.00 ), + 'anthropic/claude-3.5-sonnet' => array( 'input' => 3.00, 'output' => 15.00 ), + 'anthropic/claude-3-haiku' => array( 'input' => 0.25, 'output' => 1.25 ), + 'openai/gpt-4o-mini' => array( 'input' => 0.15, 'output' => 0.60 ), + 'openai/gpt-4o' => array( 'input' => 2.50, 'output' => 10.00 ), + ); + + /** @var string Budget enforcement mode: 'hard_stop', 'warn_only', or 'disabled'. */ + const BUDGET_MODE_HARD_STOP = 'hard_stop'; + const BUDGET_MODE_WARN_ONLY = 'warn_only'; + const BUDGET_MODE_DISABLED = 'disabled'; + + /** + * Create the llm_costs table. Called from Activator::activate(). + * + * Uses dbDelta() for idempotent CREATE/ALTER so it's safe to call on every + * plugin upgrade without manual migration logic. + */ + public static function create_table(): void { + global $wpdb; + + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + request_id VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(100) NOT NULL DEFAULT '', + provider VARCHAR(50) NOT NULL DEFAULT 'openrouter', + operation VARCHAR(50) NOT NULL DEFAULT '', + prompt_tokens INT UNSIGNED NOT NULL DEFAULT 0, + completion_tokens INT UNSIGNED NOT NULL DEFAULT 0, + total_tokens INT UNSIGNED NOT NULL DEFAULT 0, + cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0.000000, + article_id BIGINT(20) UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_created_at (created_at), + KEY idx_model (model), + KEY idx_operation (operation), + KEY idx_article (article_id) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } + + /** + * Log a single API call's token usage and cost. + * + * Called by LLM::call_openrouter() after a successful (or failed) API response. + * Extracts token counts from the OpenRouter response's `usage` field and + * calculates cost using model-specific pricing. + * + * @param string $model OpenRouter model ID (e.g., 'google/gemini-2.0-flash-001'). + * @param string $operation What this call was for: 'keywords', 'summary', or 'filter'. + * @param array $usage Token usage from API response: {prompt_tokens, completion_tokens, total_tokens}. + * @param int|null $article_id Associated article ID, if applicable. + * @param string $request_id OpenRouter request ID from response headers (for debugging). + * @param float $explicit_cost Cost reported directly by the API (OpenRouter generation.total_cost), if available. + * @return bool True on success. + */ + public static function log_api_call( + string $model, + string $operation, + array $usage, + ?int $article_id = null, + string $request_id = '', + float $explicit_cost = 0.0 + ): bool { + global $wpdb; + + $prompt_tokens = absint( $usage['prompt_tokens'] ?? 0 ); + $completion_tokens = absint( $usage['completion_tokens'] ?? 0 ); + $total_tokens = absint( $usage['total_tokens'] ?? ( $prompt_tokens + $completion_tokens ) ); + + // Use explicit cost from API if available, otherwise calculate from pricing table. + $cost_usd = $explicit_cost > 0.0 + ? $explicit_cost + : self::calculate_cost( $model, $prompt_tokens, $completion_tokens ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( + $wpdb->prefix . 'peptide_news_llm_costs', + array( + 'request_id' => sanitize_text_field( $request_id ), + 'model' => sanitize_text_field( $model ), + 'provider' => 'openrouter', + 'operation' => sanitize_text_field( $operation ), + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'total_tokens' => $total_tokens, + 'cost_usd' => $cost_usd, + 'article_id' => $article_id, + 'created_at' => current_time( 'mysql' ), + ), + array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%f', '%d', '%s' ) + ); + + if ( false === $result ) { + Peptide_News_Logger::error( 'Failed to log API cost for model ' . $model, 'cost' ); + return false; + } + + // Check budget thresholds and fire alerts if needed. + self::check_budget_alerts(); + + return true; + } + + /** + * Calculate estimated cost from token counts using model pricing. + * + * Pricing is per 1M tokens. Checks user-configured overrides first, + * then falls back to DEFAULT_MODEL_PRICING, then to zero (free model assumed). + * + * @param string $model OpenRouter model ID. + * @param int $prompt_tokens Number of input tokens. + * @param int $completion_tokens Number of output tokens. + * @return float Estimated cost in USD. + */ + public static function calculate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float { + $pricing = self::get_model_pricing( $model ); + + $input_cost = ( $prompt_tokens / 1000000.0 ) * $pricing['input']; + $output_cost = ( $completion_tokens / 1000000.0 ) * $pricing['output']; + + return round( $input_cost + $output_cost, 6 ); + } + + /** + * Get pricing for a specific model (per 1M tokens). + * + * Checks user-configured custom pricing first, then falls back to defaults. + * + * @param string $model OpenRouter model ID. + * @return array{input: float, output: float} + */ + public static function get_model_pricing( string $model ): array { + // Check user-configured custom pricing overrides. + $custom_pricing = get_option( 'peptide_news_custom_model_pricing', array() ); + if ( is_array( $custom_pricing ) && isset( $custom_pricing[ $model ] ) ) { + return array( + 'input' => (float) ( $custom_pricing[ $model ]['input'] ?? 0.0 ), + 'output' => (float) ( $custom_pricing[ $model ]['output'] ?? 0.0 ), + ); + } + + // Fall back to built-in defaults. + if ( isset( self::DEFAULT_MODEL_PRICING[ $model ] ) ) { + return self::DEFAULT_MODEL_PRICING[ $model ]; + } + + // Unknown model — assume free to avoid overcharging estimates. + return array( 'input' => 0.0, 'output' => 0.0 ); + } + + /** + * Check if the monthly budget has been exceeded. + * + * Should be called before every LLM API call to enforce the hard stop. + * Returns true if budget mode is 'hard_stop' and current month's spend + * meets or exceeds the configured limit. + * + * @return bool True if budget is exceeded and calls should be blocked. + */ + public static function is_budget_exceeded(): bool { + $mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); + $limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + + if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) { + return false; + } + + if ( self::BUDGET_MODE_HARD_STOP !== $mode ) { + return false; + } + + $current_spend = self::get_current_month_spend(); + + return $current_spend >= $limit; + } + + /** + * Get total spend for the current calendar month. + * + * Uses a short-lived transient cache (5 min) to avoid hammering the DB + * on every budget check during batch processing. + * + * @return float Total USD spent this month. + */ + public static function get_current_month_spend(): float { + $cache_key = 'peptide_news_month_spend_' . gmdate( 'Y_m' ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + return (float) $cached; + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $spend = (float) $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE(SUM(cost_usd), 0) FROM {$table} WHERE created_at >= %s", + gmdate( 'Y-m-01 00:00:00' ) + ) + ); + + set_transient( $cache_key, $spend, 5 * MINUTE_IN_SECONDS ); + + return $spend; + } + + /** + * Invalidate the monthly spend cache. + * + * Called after logging a new API call so the next budget check + * reflects the latest spend. + */ + public static function invalidate_spend_cache(): void { + delete_transient( 'peptide_news_month_spend_' . gmdate( 'Y_m' ) ); + } + + /** + * Check budget threshold alerts and log warnings. + * + * Fires at 50%, 80%, and 100% of the monthly budget. Each threshold + * is only logged once per month (tracked via a transient). + */ + private static function check_budget_alerts(): void { + $mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); + $limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + + if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) { + return; + } + + // Invalidate cache so we get fresh numbers. + self::invalidate_spend_cache(); + + $spend = self::get_current_month_spend(); + $percent = ( $spend / $limit ) * 100.0; + + $alert_key = 'peptide_news_budget_alerts_' . gmdate( 'Y_m' ); + $fired = get_transient( $alert_key ); + if ( ! is_array( $fired ) ) { + $fired = array(); + } + + $thresholds = array( 50, 80, 100 ); + + foreach ( $thresholds as $threshold ) { + if ( $percent >= $threshold && ! in_array( $threshold, $fired, true ) ) { + $message = sprintf( + 'LLM budget alert: %.0f%% of $%.2f monthly budget used ($%.4f spent).', + $percent, + $limit, + $spend + ); + Peptide_News_Logger::warning( $message, 'cost' ); + + if ( 100 === $threshold && self::BUDGET_MODE_HARD_STOP === $mode ) { + Peptide_News_Logger::error( 'Monthly LLM budget exceeded — API calls will be blocked until next month.', 'cost' ); + } + + $fired[] = $threshold; + } + } + + set_transient( $alert_key, $fired, 35 * DAY_IN_SECONDS ); + } + + /** + * Get an aggregated cost summary for a date range. + * + * Returns total spend, total tokens, request count, and per-model breakdown. + * Used by the admin cost dashboard. + * + * @param string $start_date Start date (Y-m-d format). + * @param string $end_date End date (Y-m-d format). + * @return array{total_cost: float, total_tokens: int, total_requests: int, by_model: array, by_operation: array} + */ + public static function get_cost_summary( string $start_date, string $end_date ): array { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // Overall totals. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $totals = $wpdb->get_row( + $wpdb->prepare( + "SELECT + COALESCE(SUM(cost_usd), 0) AS total_cost, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COUNT(*) AS total_requests + FROM {$table} + WHERE created_at >= %s AND created_at < %s", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ); + + // Per-model breakdown. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $by_model = $wpdb->get_results( + $wpdb->prepare( + "SELECT + model, + COUNT(*) AS requests, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(SUM(cost_usd), 0) AS total_cost + FROM {$table} + WHERE created_at >= %s AND created_at < %s + GROUP BY model + ORDER BY total_cost DESC", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ); + + // Per-operation breakdown. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $by_operation = $wpdb->get_results( + $wpdb->prepare( + "SELECT + operation, + COUNT(*) AS requests, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(SUM(cost_usd), 0) AS total_cost + FROM {$table} + WHERE created_at >= %s AND created_at < %s + GROUP BY operation + ORDER BY total_cost DESC", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ); + + return array( + 'total_cost' => (float) ( $totals->total_cost ?? 0 ), + 'total_tokens' => (int) ( $totals->total_tokens ?? 0 ), + 'total_requests' => (int) ( $totals->total_requests ?? 0 ), + 'by_model' => ! empty( $by_model ) ? $by_model : array(), + 'by_operation' => ! empty( $by_operation ) ? $by_operation : array(), + ); + } + + /** + * Get daily cost data for charting. + * + * Returns one row per day within the date range with aggregated cost and token counts. + * + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * @return array Array of objects with {date, cost, tokens, requests}. + */ + public static function get_daily_costs( string $start_date, string $end_date ): array { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT + DATE(created_at) AS date, + COALESCE(SUM(cost_usd), 0) AS cost, + COALESCE(SUM(total_tokens), 0) AS tokens, + COUNT(*) AS requests + FROM {$table} + WHERE created_at >= %s AND created_at < %s + GROUP BY DATE(created_at) + ORDER BY date ASC", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ); + + return ! empty( $results ) ? $results : array(); + } + + /** + * Get the most recent API calls for the cost log table. + * + * @param int $limit Number of recent entries to return. + * @return array Array of cost log rows. + */ + public static function get_recent_calls( int $limit = 50 ): array { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", + $limit + ) + ); + + return ! empty( $results ) ? $results : array(); + } + + /** + * AJAX handler: return cost dashboard data. + * + * Accepts 'period' param: 'day', 'week', 'month', or 'custom' with start/end dates. + * Returns summary stats and daily chart data. + * + * @since 2.4.0 + */ + public static function ajax_get_cost_data(): void { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + $period = sanitize_text_field( wp_unslash( $_GET['period'] ?? 'month' ) ); + + switch ( $period ) { + case 'day': + $start_date = gmdate( 'Y-m-d' ); + $end_date = gmdate( 'Y-m-d' ); + break; + + case 'week': + $start_date = gmdate( 'Y-m-d', strtotime( '-6 days' ) ); + $end_date = gmdate( 'Y-m-d' ); + break; + + case 'custom': + $raw_start = sanitize_text_field( wp_unslash( $_GET['start_date'] ?? '' ) ); + $raw_end = sanitize_text_field( wp_unslash( $_GET['end_date'] ?? '' ) ); + $start_date = self::validate_date( $raw_start ) ? $raw_start : gmdate( 'Y-m-01' ); + $end_date = self::validate_date( $raw_end ) ? $raw_end : gmdate( 'Y-m-d' ); + break; + + case 'month': + default: + $start_date = gmdate( 'Y-m-01' ); + $end_date = gmdate( 'Y-m-d' ); + break; + } + + $summary = self::get_cost_summary( $start_date, $end_date ); + $daily_costs = self::get_daily_costs( $start_date, $end_date ); + $recent = self::get_recent_calls( 25 ); + + // Budget context. + $budget_limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + $budget_mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); + $month_spend = self::get_current_month_spend(); + + wp_send_json_success( array( + 'summary' => $summary, + 'daily_costs' => $daily_costs, + 'recent_calls' => $recent, + 'budget' => array( + 'limit' => $budget_limit, + 'mode' => $budget_mode, + 'month_spend' => $month_spend, + 'percent_used' => $budget_limit > 0 ? round( ( $month_spend / $budget_limit ) * 100, 1 ) : 0, + ), + 'period' => array( + 'start' => $start_date, + 'end' => $end_date, + ), + ) ); + } + + /** + * Validate a date string is in Y-m-d format and represents a real date. + * + * @param string $date Date string to validate. + * @return bool True if valid Y-m-d date. + */ + private static function validate_date( string $date ): bool { + $d = \DateTime::createFromFormat( 'Y-m-d', $date ); + return $d && $d->format( 'Y-m-d' ) === $date; + } + + /** + * Prune old cost records beyond the configured retention period. + * + * Called on a monthly cron schedule. Default retention: 365 days. + * + * @return int Number of rows deleted. + */ + public static function prune_old_records(): int { + $retention_days = absint( get_option( 'peptide_news_cost_retention', 365 ) ); + if ( $retention_days < 30 ) { + $retention_days = 30; // Minimum 30 days to prevent accidental data loss. + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $deleted = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$table} WHERE created_at < %s", + gmdate( 'Y-m-d 00:00:00', strtotime( "-{$retention_days} days" ) ) + ) + ); + + if ( $deleted > 0 ) { + Peptide_News_Logger::info( sprintf( 'Pruned %d cost records older than %d days.', $deleted, $retention_days ), 'cost' ); + } + + return (int) $deleted; + } +} diff --git a/includes/class-peptide-news-deactivator.php b/includes/class-peptide-news-deactivator.php old mode 100644 new mode 100755 index 9d58e31..c294dee --- a/includes/class-peptide-news-deactivator.php +++ b/includes/class-peptide-news-deactivator.php @@ -1,4 +1,5 @@ 900, 'display' => __( 'Every 15 Minutes', 'peptide-news' ), @@ -40,7 +41,7 @@ class Peptide_News_Fetcher { * Master fetch method — called by WP-Cron. * Uses a transient lock to prevent concurrent executions. */ - public function fetch_all_sources() { + public function fetch_all_sources(): void { // Prevent overlapping fetch jobs. $lock_key = 'peptide_news_fetch_lock'; if ( get_transient( $lock_key ) ) { @@ -61,7 +62,10 @@ class Peptide_News_Fetcher { // Fetch from NewsAPI. if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) { - $api_key = get_option( 'peptide_news_newsapi_key', '' ); + $api_key_raw = get_option( 'peptide_news_newsapi_key', '' ); + $api_key = class_exists( 'Peptide_News_Encryption' ) + ? Peptide_News_Encryption::decrypt( $api_key_raw ) + : $api_key_raw; if ( ! empty( $api_key ) ) { $newsapi_articles = $this->fetch_newsapi( $api_key ); $articles = array_merge( $articles, $newsapi_articles ); @@ -128,7 +132,7 @@ class Peptide_News_Fetcher { * * @return array */ - private function fetch_rss_feeds() { + private function fetch_rss_feeds(): array { $feeds_raw = get_option( 'peptide_news_rss_feeds', '' ); $feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) ); $articles = array(); @@ -175,7 +179,7 @@ class Peptide_News_Fetcher { * @param string $feed_url * @return array Article data or empty array if processing failed. */ - private function process_rss_item( $item, $feed_url ) { + private function process_rss_item( $item, string $feed_url ): array { $pub_date = $item->get_date( 'Y-m-d H:i:s' ); if ( empty( $pub_date ) ) { $pub_date = current_time( 'mysql' ); @@ -209,7 +213,7 @@ class Peptide_News_Fetcher { * @param string $api_key * @return array */ - private function fetch_newsapi( $api_key ) { + private function fetch_newsapi( string $api_key ): array { $keywords = get_option( 'peptide_news_search_keywords', 'peptide research' ); $articles = array(); @@ -267,7 +271,7 @@ class Peptide_News_Fetcher { * @param array $article * @return bool True if new article was inserted. */ - private function store_article( $article ) { + private function store_article( array $article ): bool { global $wpdb; $table = $wpdb->prefix . 'peptide_news_articles'; @@ -310,7 +314,7 @@ class Peptide_News_Fetcher { /** * Remove articles older than the retention period. */ - private function prune_old_articles() { + private function prune_old_articles(): void { global $wpdb; $retention_days = (int) get_option( 'peptide_news_article_retention', 90 ); @@ -326,7 +330,7 @@ class Peptide_News_Fetcher { /** * Clear all article transient caches. */ - private function clear_article_cache() { + private function clear_article_cache(): void { global $wpdb; $wpdb->query( $wpdb->prepare( @@ -342,7 +346,7 @@ class Peptide_News_Fetcher { /** * Invalidate REST API response caches matching the articles pattern. */ - private function invalidate_rest_api_cache() { + private function invalidate_rest_api_cache(): void { global $wpdb; $wpdb->query( $wpdb->prepare( @@ -361,7 +365,7 @@ class Peptide_News_Fetcher { * @param SimplePie_Item $item * @return string Comma-separated categories. */ - private function extract_categories( $item ) { + private function extract_categories( $item ): string { $categories = $item->get_categories(); if ( empty( $categories ) ) { return ''; @@ -393,7 +397,7 @@ class Peptide_News_Fetcher { * @param string $article_url The article permalink. * @return string Source name or domain. */ - private function resolve_article_source( $item, $feed_url, $article_url ) { + private function resolve_article_source( $item, string $feed_url, string $article_url ): string { $feed_domain = $this->extract_domain( $feed_url ); $is_aggregator = in_array( $feed_domain, @@ -455,7 +459,7 @@ class Peptide_News_Fetcher { * @param string $url The URL to resolve. * @return string|false The final URL or false on failure. */ - private function resolve_redirect_url( $url ) { + private function resolve_redirect_url( string $url ) { if ( empty( $url ) ) { return false; } @@ -505,7 +509,7 @@ class Peptide_News_Fetcher { * * @return int Number of articles updated. */ - public function backfill_article_sources() { + public function backfill_article_sources(): int { global $wpdb; $table = $wpdb->prefix . 'peptide_news_articles'; @@ -572,7 +576,7 @@ class Peptide_News_Fetcher { /** * AJAX handler for the backfill action. */ - public function ajax_backfill_sources() { + public function ajax_backfill_sources(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { @@ -589,7 +593,7 @@ class Peptide_News_Fetcher { * @param string $url * @return string */ - private function extract_domain( $url ) { + private function extract_domain( string $url ): string { $host = wp_parse_url( $url, PHP_URL_HOST ); return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown'; } @@ -599,7 +603,7 @@ class Peptide_News_Fetcher { * * @param string $message */ - private function log_error( $message ) { + private function log_error( string $message ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[Peptide News] ' . $message ); } diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php old mode 100644 new mode 100755 index 25058e6..6e78213 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -1,4 +1,5 @@ '', 'summary' => '', 'success' => false ); } @@ -67,25 +88,44 @@ class Peptide_News_LLM { 'errors' => array(), ); - $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); + $api_key = self::get_api_key(); $any_success = false; + $article_id = (int) $article->id; // --- Keyword extraction --- $keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); $need_keywords = $force || empty( $article->tags ); if ( $need_keywords && self::is_valid_model( $keywords_model ) ) { - $keywords_prompt = self::build_keywords_prompt( $article ); - $keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt ); - - 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' ); + // Budget gate: block if monthly limit reached. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) { + $results['errors'][] = 'Monthly LLM budget exceeded — keywords skipped.'; + Peptide_News_Logger::warning( 'Budget exceeded, skipping keywords for article #' . $article_id, 'cost' ); } else { - $err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message(); - $results['errors'][] = $err_msg; - Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' ); + $keywords_prompt = self::build_keywords_prompt( $article ); + $keywords_response = self::call_openrouter_with_usage( $api_key, $keywords_model, $keywords_prompt ); + + if ( ! is_wp_error( $keywords_response['content'] ) ) { + $results['keywords'] = self::sanitize_keywords( $keywords_response['content'] ); + $any_success = true; + Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article_id, 'llm' ); + } else { + $err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_response['content']->get_error_message(); + $results['errors'][] = $err_msg; + Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article_id . ': ' . $keywords_response['content']->get_error_message(), 'llm' ); + } + + // Log cost regardless of success — failed calls still consume tokens. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $keywords_response['usage'] ) ) { + Peptide_News_Cost_Tracker::log_api_call( + $keywords_model, + 'keywords', + $keywords_response['usage'], + $article_id, + $keywords_response['request_id'] ?? '', + $keywords_response['cost'] ?? 0.0 + ); + } } } elseif ( $need_keywords ) { $results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model; @@ -97,18 +137,36 @@ class Peptide_News_LLM { $need_summary = $force || empty( $article->ai_summary ); if ( $need_summary && self::is_valid_model( $summary_model ) ) { - $summary_prompt = self::build_summary_prompt( $article ); - $summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt ); - - if ( ! is_wp_error( $summary_result ) ) { - // 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' ); + // Budget gate: re-check in case keyword extraction pushed us over. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) { + $results['errors'][] = 'Monthly LLM budget exceeded — summary skipped.'; + Peptide_News_Logger::warning( 'Budget exceeded, skipping summary for article #' . $article_id, 'cost' ); } else { - $err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message(); - $results['errors'][] = $err_msg; - Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' ); + $summary_prompt = self::build_summary_prompt( $article ); + $summary_response = self::call_openrouter_with_usage( $api_key, $summary_model, $summary_prompt ); + + if ( ! is_wp_error( $summary_response['content'] ) ) { + // Strip all HTML tags from LLM output to prevent XSS. + $results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_response['content'] ) ); + $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_response['content']->get_error_message(); + $results['errors'][] = $err_msg; + Peptide_News_Logger::error( 'Summarization failed for article #' . $article_id . ': ' . $summary_response['content']->get_error_message(), 'llm' ); + } + + // Log cost regardless of success. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $summary_response['usage'] ) ) { + Peptide_News_Cost_Tracker::log_api_call( + $summary_model, + 'summary', + $summary_response['usage'], + $article_id, + $summary_response['request_id'] ?? '', + $summary_response['cost'] ?? 0.0 + ); + } } } elseif ( $need_summary ) { $results['errors'][] = 'Invalid summary model ID: ' . $summary_model; @@ -119,7 +177,7 @@ class Peptide_News_LLM { // --- Persist results --- if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) { - self::save_results( $article->id, $results, $force ); + self::save_results( $article_id, $results, $force ); } return $results; @@ -136,7 +194,7 @@ class Peptide_News_LLM { * @param bool $override_limit When true, ignore the admin "max per cycle" cap. * @return int Number of articles successfully processed. */ - public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) { + public static function process_unanalyzed( int $batch_size = 10, bool $override_limit = false ): int { if ( ! self::is_enabled() ) { return 0; } @@ -221,7 +279,7 @@ class Peptide_News_LLM { * @param object $article * @return string */ - private static function build_keywords_prompt( $article ) { + private static function build_keywords_prompt( object $article ): string { $text = $article->title; if ( ! empty( $article->excerpt ) ) { $text .= "\n\n" . $article->excerpt; @@ -245,7 +303,7 @@ class Peptide_News_LLM { * @param object $article * @return string */ - private static function build_summary_prompt( $article ) { + private static function build_summary_prompt( object $article ): string { $title = trim( $article->title ?? '' ); $excerpt = trim( $article->excerpt ?? '' ); $content = trim( $article->content ?? '' ); @@ -287,7 +345,7 @@ class Peptide_News_LLM { * @param string $url The article URL. * @return string Cleaned plain text or empty string on failure. */ - private static function fetch_article_text( $url ) { + private static function fetch_article_text( string $url ): string { $response = wp_remote_get( $url, array( 'timeout' => 10, 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0; +https://peptiderepo.com)', @@ -337,12 +395,38 @@ class Peptide_News_LLM { /** * Call the OpenRouter API with retry logic for 429 rate limits. * + * Backward-compatible wrapper that returns only the content string. + * Internally delegates to call_openrouter_with_usage(). + * * @param string $api_key * @param string $model * @param string $prompt * @return string|WP_Error The response text or an error. */ - public static function call_openrouter( $api_key, $model, $prompt ) { + public static function call_openrouter( string $api_key, string $model, string $prompt ) { + $result = self::call_openrouter_with_usage( $api_key, $model, $prompt ); + return $result['content']; + } + + /** + * Call the OpenRouter API and return content + token usage data. + * + * Returns an array with 'content' (string|WP_Error), 'usage' (token counts), + * 'request_id' (for debugging), and 'cost' (if API reports it). + * + * @param string $api_key + * @param string $model + * @param string $prompt + * @return array{content: string|WP_Error, usage: array, request_id: string, cost: float} + */ + public static function call_openrouter_with_usage( string $api_key, string $model, string $prompt ): array { + $result = array( + 'content' => '', + 'usage' => array(), + 'request_id' => '', + 'cost' => 0.0, + ); + $body = array( 'model' => $model, 'messages' => array( @@ -371,7 +455,8 @@ class Peptide_News_LLM { ) ); if ( is_wp_error( $response ) ) { - return $response; + $result['content'] = $response; + return $result; } $status = wp_remote_retrieve_response_code( $response ); @@ -391,29 +476,55 @@ class Peptide_News_LLM { $data = json_decode( wp_remote_retrieve_body( $response ), true ); + // Extract usage data from response regardless of success/failure. + // OpenRouter includes usage in the response body: data.usage.{prompt_tokens, completion_tokens, total_tokens} + if ( is_array( $data ) && isset( $data['usage'] ) && is_array( $data['usage'] ) ) { + $result['usage'] = array( + 'prompt_tokens' => (int) ( $data['usage']['prompt_tokens'] ?? 0 ), + 'completion_tokens' => (int) ( $data['usage']['completion_tokens'] ?? 0 ), + 'total_tokens' => (int) ( $data['usage']['total_tokens'] ?? 0 ), + ); + } + + // Extract the request ID from response for debugging/reconciliation. + if ( is_array( $data ) && isset( $data['id'] ) ) { + $result['request_id'] = (string) $data['id']; + } + + // OpenRouter may include generation cost in data.usage.total_cost or similar. + if ( is_array( $data ) && isset( $data['usage']['total_cost'] ) ) { + $result['cost'] = (float) $data['usage']['total_cost']; + } + if ( $status < 200 || $status >= 300 ) { $error_msg = 'HTTP ' . $status; if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) { $error_msg = $data['error']['message']; } - return new WP_Error( 'openrouter_error', $error_msg ); + $result['content'] = new WP_Error( 'openrouter_error', $error_msg ); + return $result; } // Validate the response structure thoroughly. if ( ! is_array( $data ) ) { - return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); + $result['content'] = new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); + return $result; } if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) { - return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); + $result['content'] = new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); + return $result; } if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) { - return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); + $result['content'] = new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); + return $result; } if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) { - return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); + $result['content'] = new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); + return $result; } - return trim( $data['choices'][0]['message']['content'] ); + $result['content'] = trim( $data['choices'][0]['message']['content'] ); + return $result; } /** @@ -422,7 +533,7 @@ class Peptide_News_LLM { * @param string $raw Raw comma-separated keywords. * @return string Cleaned, deduplicated, comma-separated list. */ - private static function sanitize_keywords( $raw ) { + private static function sanitize_keywords( string $raw ): string { // Strip all HTML first to prevent XSS. $raw = wp_strip_all_tags( $raw ); @@ -451,7 +562,7 @@ class Peptide_News_LLM { * @param array $results Array with 'keywords' and 'summary'. * @param bool $force Overwrite existing values. */ - private static function save_results( $article_id, $results, $force = false ) { + private static function save_results( int $article_id, array $results, bool $force = false ): void { global $wpdb; $table = $wpdb->prefix . 'peptide_news_articles'; @@ -487,7 +598,7 @@ class Peptide_News_LLM { * * @since 2.0.1 */ - public static function ajax_generate_summaries() { + public static function ajax_generate_summaries(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); @@ -557,7 +668,7 @@ class Peptide_News_LLM { /** * Clear all article transient caches. */ - private static function clear_article_cache() { + private static function clear_article_cache(): void { global $wpdb; $wpdb->query( $wpdb->prepare( @@ -575,7 +686,7 @@ class Peptide_News_LLM { * * @param string $message */ - private static function log_error( $message ) { + private static function log_error( string $message ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[Peptide News LLM] ' . $message ); } diff --git a/includes/class-peptide-news-loader.php b/includes/class-peptide-news-loader.php old mode 100644 new mode 100755 index 40ea35c..51366d3 --- a/includes/class-peptide-news-loader.php +++ b/includes/class-peptide-news-loader.php @@ -1,4 +1,5 @@ actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args ); } @@ -64,7 +65,7 @@ class Peptide_News_Loader { * @param int $priority Optional. The priority at which the function should be fired. Default is 10. * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. */ - public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { + public function add_filter( string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1 ): void { $this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args ); } @@ -81,7 +82,7 @@ class Peptide_News_Loader { * @param int $accepted_args The number of arguments that should be passed to the $callback. * @return array The collection of actions and filters registered with WordPress. */ - private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) { + private function add( array $hooks, string $hook, object $component, string $callback, int $priority, int $accepted_args ): array { $hooks[] = array( 'hook' => $hook, 'component' => $component, @@ -98,7 +99,7 @@ class Peptide_News_Loader { * * @since 1.0.0 */ - public function run() { + public function run(): void { foreach ( $this->filters as $hook ) { add_filter( $hook['hook'], diff --git a/includes/class-peptide-news-logger.php b/includes/class-peptide-news-logger.php old mode 100644 new mode 100755 index 20cfbdf..2966a6f --- a/includes/class-peptide-news-logger.php +++ b/includes/class-peptide-news-logger.php @@ -1,4 +1,5 @@ prefix . 'peptide_news_log'; } @@ -37,7 +38,7 @@ class Peptide_News_Logger { * @param string $message Human-readable message. * @param string $context Category tag (fetch, llm, admin, cron, general). */ - public static function log( $level, $message, $context = 'general' ) { + public static function log( string $level, string $message, string $context = 'general' ): void { global $wpdb; $wpdb->insert( @@ -56,22 +57,22 @@ class Peptide_News_Logger { } /** Convenience: info level. */ - public static function info( $message, $context = 'general' ) { + public static function info( string $message, string $context = 'general' ): void { self::log( self::LEVEL_INFO, $message, $context ); } /** Convenience: warning level. */ - public static function warning( $message, $context = 'general' ) { + public static function warning( string $message, string $context = 'general' ): void { self::log( self::LEVEL_WARNING, $message, $context ); } /** Convenience: error level. */ - public static function error( $message, $context = 'general' ) { + public static function error( string $message, string $context = 'general' ): void { self::log( self::LEVEL_ERROR, $message, $context ); } /** Convenience: debug level (only writes when WP_DEBUG is on). */ - public static function debug( $message, $context = 'general' ) { + public static function debug( string $message, string $context = 'general' ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { self::log( self::LEVEL_DEBUG, $message, $context ); } @@ -94,7 +95,7 @@ class Peptide_News_Logger { * } * @return array { 'rows' => array, 'total' => int } */ - public static function get_logs( $args = array() ) { + public static function get_logs( array $args = array() ): array { global $wpdb; $defaults = array( @@ -145,7 +146,7 @@ class Peptide_News_Logger { /** * Remove rows beyond the MAX_ROWS cap. */ - private static function maybe_prune() { + private static function maybe_prune(): void { global $wpdb; $table = self::table(); @@ -168,7 +169,7 @@ class Peptide_News_Logger { /** * Clear all log entries. */ - public static function clear() { + public static function clear(): void { global $wpdb; $wpdb->query( "TRUNCATE TABLE " . self::table() ); } @@ -180,7 +181,7 @@ class Peptide_News_Logger { /** * AJAX: fetch paginated log entries. */ - public static function ajax_get_logs() { + public static function ajax_get_logs(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { @@ -210,7 +211,7 @@ class Peptide_News_Logger { /** * AJAX: clear all log entries. */ - public static function ajax_clear_logs() { + public static function ajax_clear_logs(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { @@ -230,7 +231,7 @@ class Peptide_News_Logger { /** * Create the log table (called from activator / upgrade check). */ - public static function create_table() { + public static function create_table(): void { global $wpdb; $table = self::table(); diff --git a/includes/class-peptide-news-rest-api.php b/includes/class-peptide-news-rest-api.php old mode 100644 new mode 100755 index 8dc07b9..f697f4b --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -1,4 +1,5 @@ array( 'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), @@ -216,7 +217,7 @@ class Peptide_News_Rest_API { * @param string $source_url The source URL (used to extract domain-based publisher names). * @return string Cleaned text. */ - private function strip_source_suffix( $text, $source, $source_url = '' ) { + private function strip_source_suffix( string $text, string $source, string $source_url = '' ): string { if ( empty( $text ) ) { return $text; } @@ -387,7 +388,7 @@ class Peptide_News_Rest_API { * * @since 2.1.0 */ - public static function ajax_delete_articles() { + public static function ajax_delete_articles(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php old mode 100644 new mode 100755 index e6120cf..e3b5fd1 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -1,4 +1,5 @@ plugin_name, $this->version ); $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' ); @@ -62,7 +65,7 @@ class Peptide_News { /** * Register public-facing hooks. */ - private function define_public_hooks() { + private function define_public_hooks(): void { $public = new Peptide_News_Public( $this->plugin_name, $this->version ); $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' ); @@ -76,7 +79,7 @@ class Peptide_News { /** * Register WP-Cron hooks for scheduled fetching. */ - private function define_cron_hooks() { + private function define_cron_hooks(): void { $fetcher = new Peptide_News_Fetcher(); $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); @@ -86,12 +89,13 @@ class Peptide_News { $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' ); $this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' ); + $this->loader->add_action( 'wp_ajax_peptide_news_get_cost_data', 'Peptide_News_Cost_Tracker', 'ajax_get_cost_data' ); } /** * Register REST API hooks. */ - private function define_rest_hooks() { + private function define_rest_hooks(): void { $rest = new Peptide_News_Rest_API(); $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); @@ -100,19 +104,19 @@ class Peptide_News { /** * Run the loader to execute all registered hooks. */ - public function run() { + public function run(): void { $this->loader->run(); } - public function get_plugin_name() { + public function get_plugin_name(): string { return $this->plugin_name; } - public function get_version() { + public function get_version(): string { return $this->version; } - public function get_loader() { + public function get_loader(): Peptide_News_Loader { return $this->loader; } } diff --git a/languages/peptide-news.pot b/languages/peptide-news.pot old mode 100644 new mode 100755 diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php old mode 100644 new mode 100755 index 5d1e878..8122ad9 --- 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.2.0 + * Version: 2.4.0 * Author: Peptide News Team * Author URI: https://github.com/peptiderepo * License: GPL-2.0+ @@ -14,6 +14,8 @@ * Requires PHP: 7.4 */ +declare( strict_types=1 ); + // Abort if called directly. if ( ! defined( 'WPINC' ) ) { die; @@ -22,7 +24,7 @@ if ( ! defined( 'WPINC' ) ) { /** * Current plugin version — follows SemVer. */ -define( 'PEPTIDE_NEWS_VERSION', '2.2.0' ); +define( 'PEPTIDE_NEWS_VERSION', '2.4.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__ ) ); @@ -30,7 +32,7 @@ define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); /** * Runs during plugin activation. */ -function peptide_news_activate() { +function peptide_news_activate(): void { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; Peptide_News_Activator::activate(); } @@ -38,7 +40,7 @@ function peptide_news_activate() { /** * Runs during plugin deactivation. */ -function peptide_news_deactivate() { +function peptide_news_deactivate(): void { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; Peptide_News_Deactivator::deactivate(); } @@ -56,7 +58,7 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news.php'; * This handles existing installs that upgrade from 1.0.0 to 1.1.0 * without re-activating the plugin. */ -function peptide_news_check_db_upgrade() { +function peptide_news_check_db_upgrade(): void { $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'; @@ -69,7 +71,7 @@ add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' ); /** * Begins execution of the plugin. */ -function peptide_news_run() { +function peptide_news_run(): void { $plugin = new Peptide_News(); $plugin->run(); } diff --git a/public/class-peptide-news-public.php b/public/class-peptide-news-public.php old mode 100644 new mode 100755 index 7e961a4..0ff8228 --- a/public/class-peptide-news-public.php +++ b/public/class-peptide-news-public.php @@ -1,4 +1,5 @@ plugin_name = $plugin_name; $this->version = $version; } @@ -24,7 +25,7 @@ class Peptide_News_Public { /** * Register front-end CSS (enqueued on demand by the shortcode/widget). */ - public function enqueue_styles() { + public function enqueue_styles(): void { wp_register_style( $this->plugin_name, PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css', @@ -40,7 +41,7 @@ class Peptide_News_Public { * Loads React (from WordPress core), the feed component, and * passes configuration via wp_localize_script. */ - public function enqueue_scripts() { + public function enqueue_scripts(): void { // React feed component (depends on WP-bundled React). wp_register_script( $this->plugin_name . '-feed', @@ -54,14 +55,14 @@ class Peptide_News_Public { /** * Register the shortcode. */ - public function register_shortcode() { + public function register_shortcode(): void { add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) ); } /** * Register the widget. */ - public function register_widget() { + public function register_widget(): void { register_widget( 'Peptide_News_Widget' ); } @@ -75,7 +76,7 @@ class Peptide_News_Public { * @param array $atts Shortcode attributes. * @return string HTML output. */ - public function render_shortcode( $atts ) { + public function render_shortcode( $atts ): string { $atts = shortcode_atts( array( 'count' => get_option( 'peptide_news_articles_count', 10 ), ), $atts, 'peptide_news' ); @@ -102,7 +103,7 @@ class Peptide_News_Public { /** * Handle AJAX click tracking with basic rate limiting. */ - public function handle_click_tracking() { + public function handle_click_tracking(): void { check_ajax_referer( 'peptide_news_track', 'nonce' ); $article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0; @@ -142,7 +143,7 @@ class Peptide_News_Public { * * @return string */ - private function get_or_create_session_id() { + private function get_or_create_session_id(): string { if ( isset( $_COOKIE['pn_session_id'] ) ) { return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) ); } diff --git a/public/css/public-style.css b/public/css/public-style.css old mode 100644 new mode 100755 diff --git a/public/images/default-thumb.png b/public/images/default-thumb.png old mode 100644 new mode 100755 diff --git a/public/js/peptide-news-feed.js b/public/js/peptide-news-feed.js old mode 100644 new mode 100755 diff --git a/public/js/public-script.js b/public/js/public-script.js old mode 100644 new mode 100755 diff --git a/public/react-app/PeptideNewsFeed.jsx b/public/react-app/PeptideNewsFeed.jsx old mode 100644 new mode 100755