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 '';
+ foreach ( $modes as $mode => $label ) {
+ printf(
+ '%s ',
+ esc_attr( $mode ),
+ selected( $value, $mode, false ),
+ esc_html( $label )
+ );
+ }
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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