Deploy 6796121 from main
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : '';
|
||||
@@ -27,126 +27,126 @@ $total_articles = count( $top_articles );
|
||||
?>
|
||||
|
||||
<div class="wrap peptide-news-dashboard">
|
||||
<h1><?php esc_html_e( 'Peptide News Analytics', 'peptide-news' ); ?></h1>
|
||||
<h1><?php esc_html_e( 'Peptide News Analytics', 'peptide-news' ); ?></h1>
|
||||
|
||||
<!-- Date range filter -->
|
||||
<form method="get" class="pn-date-filter">
|
||||
<input type="hidden" name="page" value="peptide-news-dashboard" />
|
||||
<label for="start_date"><?php esc_html_e( 'From:', 'peptide-news' ); ?></label>
|
||||
<input type="date" id="start_date" name="start_date" value="<?php echo esc_attr( $start_date ); ?>" />
|
||||
<label for="end_date"><?php esc_html_e( 'To:', 'peptide-news' ); ?></label>
|
||||
<input type="date" id="end_date" name="end_date" value="<?php echo esc_attr( $end_date ); ?>" />
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e( 'Filter', 'peptide-news' ); ?></button>
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=peptide-news-dashboard&start_date=' . urlencode( $start_date ) . '&end_date=' . urlencode( $end_date ) . '&export=csv' ), 'peptide_news_export_csv' ) ); ?>" class="button"><?php esc_html_e( 'Export CSV', 'peptide-news' ); ?></a>
|
||||
</form>
|
||||
<!-- Date range filter -->
|
||||
<form method="get" class="pn-date-filter">
|
||||
<input type="hidden" name="page" value="peptide-news-dashboard" />
|
||||
<label for="start_date"><?php esc_html_e( 'From:', 'peptide-news' ); ?></label>
|
||||
<input type="date" id="start_date" name="start_date" value="<?php echo esc_attr( $start_date ); ?>" />
|
||||
<label for="end_date"><?php esc_html_e( 'To:', 'peptide-news' ); ?></label>
|
||||
<input type="date" id="end_date" name="end_date" value="<?php echo esc_attr( $end_date ); ?>" />
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e( 'Filter', 'peptide-news' ); ?></button>
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=peptide-news-dashboard&start_date=' . urlencode( $start_date ) . '&end_date=' . urlencode( $end_date ) . '&export=csv' ), 'peptide_news_export_csv' ) ); ?>" class="button"><?php esc_html_e( 'Export CSV', 'peptide-news' ); ?></a>
|
||||
</form>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="pn-summary-cards">
|
||||
<div class="pn-card">
|
||||
<h3><?php echo esc_html( number_format( $total_clicks ) ); ?></h3>
|
||||
<p><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
<div class="pn-card">
|
||||
<h3><?php echo esc_html( number_format( $total_unique ) ); ?></h3>
|
||||
<p><?php esc_html_e( 'Unique Visitors', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
<div class="pn-card">
|
||||
<h3><?php echo esc_html( $total_articles ); ?></h3>
|
||||
<p><?php esc_html_e( 'Articles Clicked', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
<div class="pn-card">
|
||||
<h3><?php echo $total_articles > 0 ? esc_html( round( $total_clicks / $total_articles, 1 ) ) : '0'; ?></h3>
|
||||
<p><?php esc_html_e( 'Avg Clicks / Article', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Summary cards -->
|
||||
<div class="pn-summary-cards">
|
||||
<div class="pn-card">
|
||||
<h3><?php echo esc_html( number_format( $total_clicks ) ); ?></h3>
|
||||
<p><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
<div class="pn-card">
|
||||
<h3><?php echo esc_html( number_format( $total_unique ) ); ?></h3>
|
||||
<p><?php esc_html_e( 'Unique Visitors', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
<div class="pn-card">
|
||||
<h3><?php echo esc_html( $total_articles ); ?></h3>
|
||||
<p><?php esc_html_e( 'Articles Clicked', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
<div class="pn-card">
|
||||
<h3><?php echo $total_articles > 0 ? esc_html( round( $total_clicks / $total_articles, 1 ) ) : '0'; ?></h3>
|
||||
<p><?php esc_html_e( 'Avg Clicks / Article', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<div class="pn-charts-row">
|
||||
<div class="pn-chart-box pn-chart-wide">
|
||||
<h2><?php esc_html_e( 'Click Trends', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-trends-chart" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Charts row -->
|
||||
<div class="pn-charts-row">
|
||||
<div class="pn-chart-box pn-chart-wide">
|
||||
<h2><?php esc_html_e( 'Click Trends', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-trends-chart" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pn-charts-row">
|
||||
<div class="pn-chart-box">
|
||||
<h2><?php esc_html_e( 'Device Breakdown', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-devices-chart" height="250"></canvas>
|
||||
</div>
|
||||
<div class="pn-chart-box">
|
||||
<h2><?php esc_html_e( 'Source Performance', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-sources-chart" height="250"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pn-charts-row">
|
||||
<div class="pn-chart-box">
|
||||
<h2><?php esc_html_e( 'Device Breakdown', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-devices-chart" height="250"></canvas>
|
||||
</div>
|
||||
<div class="pn-chart-box">
|
||||
<h2><?php esc_html_e( 'Source Performance', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-sources-chart" height="250"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top articles table -->
|
||||
<div class="pn-table-section">
|
||||
<h2><?php esc_html_e( 'Top Articles', 'peptide-news' ); ?></h2>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5%">#</th>
|
||||
<th style="width:45%"><?php esc_html_e( 'Article', 'peptide-news' ); ?></th>
|
||||
<th style="width:15%"><?php esc_html_e( 'Source', 'peptide-news' ); ?></th>
|
||||
<th style="width:15%"><?php esc_html_e( 'Categories', 'peptide-news' ); ?></th>
|
||||
<th style="width:10%"><?php esc_html_e( 'Clicks', 'peptide-news' ); ?></th>
|
||||
<th style="width:10%"><?php esc_html_e( 'Unique', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ( empty( $top_articles ) ) : ?>
|
||||
<tr><td colspan="6"><?php esc_html_e( 'No click data for this period.', 'peptide-news' ); ?></td></tr>
|
||||
<?php else : ?>
|
||||
<?php foreach( $top_articles as $i => $article ) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html( $i + 1 ); ?></td>
|
||||
<td><a href="<?php echo esc_url( $article->source_url ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $article->title ); ?></a></td>
|
||||
<td><?php echo esc_html( $article->source ); ?></td>
|
||||
<td><?php echo esc_html( $article->categories ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format( $article->total_clicks ) ); ?></strong></td>
|
||||
<td><?php echo esc_html( number_format( $article->total_unique ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Top articles table -->
|
||||
<div class="pn-table-section">
|
||||
<h2><?php esc_html_e( 'Top Articles', 'peptide-news' ); ?></h2>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5%">#</th>
|
||||
<th style="width:45%"><?php esc_html_e( 'Article', 'peptide-news' ); ?></th>
|
||||
<th style="width:15%"><?php esc_html_e( 'Source', 'peptide-news' ); ?></th>
|
||||
<th style="width:15%"><?php esc_html_e( 'Categories', 'peptide-news' ); ?></th>
|
||||
<th style="width:10%"><?php esc_html_e( 'Clicks', 'peptide-news' ); ?></th>
|
||||
<th style="width:10%"><?php esc_html_e( 'Unique', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ( empty( $top_articles ) ) : ?>
|
||||
<tr><td colspan="6"><?php esc_html_e( 'No click data for this period.', 'peptide-news' ); ?></td></tr>
|
||||
<?php else : ?>
|
||||
<?php foreach ( $top_articles as $i => $article ) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html( $i + 1 ); ?></td>
|
||||
<td><a href="<?php echo esc_url( $article->source_url ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $article->title ); ?></a></td>
|
||||
<td><?php echo esc_html( $article->source ); ?></td>
|
||||
<td><?php echo esc_html( $article->categories ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format( $article->total_clicks ) ); ?></strong></td>
|
||||
<td><?php echo esc_html( number_format( $article->total_unique ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Popular topics table -->
|
||||
<div class="pn-table-section">
|
||||
<h2><?php esc_html_e( 'Popular Topics', 'peptide-news' ); ?></h2>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5%">#</th>
|
||||
<th style="width:45%"><?php esc_html_e( 'Topic', 'peptide-news' ); ?></th>
|
||||
<th style="width:25%"><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></th>
|
||||
<th style="width:25%"><?php esc_html_e( 'Articles', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ( empty( $topics ) ) : ?>
|
||||
<tr><td colspan="4"><?php esc_html_e( 'No topic data for this period.', 'peptide-news' ); ?></td></tr>
|
||||
<?php else : ?>
|
||||
<?php foreach( $topics as $i => $topic ) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html( $i + 1 ); ?></td>
|
||||
<td><?php echo esc_html( $topic['topic'] ); ?></td>
|
||||
<td><?php echo esc_html( number_format( $topic['total_clicks'] ) ); ?></td>
|
||||
<td><?php echo esc_html( $topic['article_count'] ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Popular topics table -->
|
||||
<div class="pn-table-section">
|
||||
<h2><?php esc_html_e( 'Popular Topics', 'peptide-news' ); ?></h2>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5%">#</th>
|
||||
<th style="width:45%"><?php esc_html_e( 'Topic', 'peptide-news' ); ?></th>
|
||||
<th style="width:25%"><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></th>
|
||||
<th style="width:25%"><?php esc_html_e( 'Articles', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ( empty( $topics ) ) : ?>
|
||||
<tr><td colspan="4"><?php esc_html_e( 'No topic data for this period.', 'peptide-news' ); ?></td></tr>
|
||||
<?php else : ?>
|
||||
<?php foreach ( $topics as $i => $topic ) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html( $i + 1 ); ?></td>
|
||||
<td><?php echo esc_html( $topic['topic'] ); ?></td>
|
||||
<td><?php echo esc_html( number_format( $topic['total_clicks'] ) ); ?></td>
|
||||
<td><?php echo esc_html( $topic['article_count'] ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Pass PHP data to JS for Chart.js rendering.
|
||||
var peptideNewsDashboardData = {
|
||||
trends: <?php echo wp_json_encode( $trends ); ?>,
|
||||
devices: <?php echo wp_json_encode( $devices ); ?>,
|
||||
sources: <?php echo wp_json_encode( $sources ); ?>
|
||||
trends: <?php echo wp_json_encode( $trends ); ?>,
|
||||
devices: <?php echo wp_json_encode( $devices ); ?>,
|
||||
sources: <?php echo wp_json_encode( $sources ); ?>
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -8,180 +8,180 @@
|
||||
*/
|
||||
class Peptide_News_Activator {
|
||||
|
||||
/**
|
||||
* Create custom tables and schedule cron.
|
||||
*/
|
||||
public static function activate() {
|
||||
self::create_tables();
|
||||
self::set_default_options();
|
||||
self::schedule_cron();
|
||||
/**
|
||||
* Create custom tables and schedule cron.
|
||||
*/
|
||||
public static function activate() {
|
||||
self::create_tables();
|
||||
self::set_default_options();
|
||||
self::schedule_cron();
|
||||
|
||||
// Create the log table (idempotent via dbDelta).
|
||||
if ( class_exists( 'Peptide_News_Logger' ) ) {
|
||||
Peptide_News_Logger::create_table();
|
||||
}
|
||||
}
|
||||
// Create the log table (idempotent via dbDelta).
|
||||
if ( class_exists( 'Peptide_News_Logger' ) ) {
|
||||
Peptide_News_Logger::create_table();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the articles and analytics database tables.
|
||||
*/
|
||||
private static function create_tables() {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Create the articles and analytics database tables.
|
||||
*/
|
||||
private static function create_tables() {
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
// Articles table — stores fetched news data for analysis.
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$sql_articles = "CREATE TABLE {$articles_table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
source VARCHAR(100) NOT NULL DEFAULT '',
|
||||
source_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
excerpt TEXT NOT NULL,
|
||||
content LONGTEXT NOT NULL,
|
||||
author VARCHAR(255) NOT NULL DEFAULT '',
|
||||
thumbnail_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
thumbnail_local VARCHAR(512) NOT NULL DEFAULT '',
|
||||
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
categories VARCHAR(500) NOT NULL DEFAULT '',
|
||||
tags VARCHAR(500) NOT NULL DEFAULT '',
|
||||
language VARCHAR(10) NOT NULL DEFAULT 'en',
|
||||
sentiment_score FLOAT DEFAULT NULL,
|
||||
ai_summary TEXT NOT NULL,
|
||||
hash VARCHAR(64) NOT NULL DEFAULT '',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY hash (hash),
|
||||
KEY idx_published (published_at),
|
||||
KEY idx_source (source),
|
||||
KEY idx_active_published (is_active, published_at)
|
||||
) {$charset_collate};";
|
||||
// Articles table — stores fetched news data for analysis.
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$sql_articles = "CREATE TABLE {$articles_table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
source VARCHAR(100) NOT NULL DEFAULT '',
|
||||
source_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
excerpt TEXT NOT NULL,
|
||||
content LONGTEXT NOT NULL,
|
||||
author VARCHAR(255) NOT NULL DEFAULT '',
|
||||
thumbnail_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
thumbnail_local VARCHAR(512) NOT NULL DEFAULT '',
|
||||
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
categories VARCHAR(500) NOT NULL DEFAULT '',
|
||||
tags VARCHAR(500) NOT NULL DEFAULT '',
|
||||
language VARCHAR(10) NOT NULL DEFAULT 'en',
|
||||
sentiment_score FLOAT DEFAULT NULL,
|
||||
ai_summary TEXT NOT NULL,
|
||||
hash VARCHAR(64) NOT NULL DEFAULT '',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY hash (hash),
|
||||
KEY idx_published (published_at),
|
||||
KEY idx_source (source),
|
||||
KEY idx_active_published (is_active, published_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Click analytics table — one row per click event.
|
||||
$clicks_table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
$sql_clicks = "CREATE TABLE {$clicks_table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
article_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
clicked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_ip VARCHAR(45) NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL,
|
||||
referrer_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
page_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
session_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
user_id BIGINT(20) UNSIGNED DEFAULT NULL,
|
||||
country VARCHAR(2) NOT NULL DEFAULT '',
|
||||
device_type VARCHAR(20) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_article (article_id),
|
||||
KEY idx_clicked_at (clicked_at),
|
||||
KEY idx_session (session_id),
|
||||
KEY idx_article_date (article_id, clicked_at)
|
||||
) {$charset_collate};";
|
||||
// Click analytics table — one row per click event.
|
||||
$clicks_table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
$sql_clicks = "CREATE TABLE {$clicks_table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
article_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
clicked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_ip VARCHAR(45) NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL,
|
||||
referrer_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
page_url VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
session_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
user_id BIGINT(20) UNSIGNED DEFAULT NULL,
|
||||
country VARCHAR(2) NOT NULL DEFAULT '',
|
||||
device_type VARCHAR(20) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_article (article_id),
|
||||
KEY idx_clicked_at (clicked_at),
|
||||
KEY idx_session (session_id),
|
||||
KEY idx_article_date (article_id, clicked_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Daily aggregates table — for fast trend queries.
|
||||
$daily_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$sql_daily = "CREATE TABLE {$daily_table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
article_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
stat_date DATE NOT NULL,
|
||||
click_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
unique_visitors INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY idx_article_date (article_id, stat_date),
|
||||
KEY idx_date (stat_date)
|
||||
) {$charset_collate};";
|
||||
// Daily aggregates table — for fast trend queries.
|
||||
$daily_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$sql_daily = "CREATE TABLE {$daily_table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
article_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
stat_date DATE NOT NULL,
|
||||
click_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
unique_visitors INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY idx_article_date (article_id, stat_date),
|
||||
KEY idx_date (stat_date)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
dbDelta( $sql_articles );
|
||||
dbDelta( $sql_clicks );
|
||||
dbDelta( $sql_daily );
|
||||
dbDelta( $sql_articles );
|
||||
dbDelta( $sql_clicks );
|
||||
dbDelta( $sql_daily );
|
||||
|
||||
// Add foreign key constraints if they don't exist.
|
||||
self::add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table );
|
||||
// Add foreign key constraints if they don't exist.
|
||||
self::add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table );
|
||||
|
||||
update_option( 'peptide_news_db_version', PEPTIDE_NEWS_VERSION );
|
||||
}
|
||||
update_option( 'peptide_news_db_version', PEPTIDE_NEWS_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensible default option values.
|
||||
*/
|
||||
private static function set_default_options() {
|
||||
$defaults = array(
|
||||
'fetch_interval' => 'twicedaily',
|
||||
'articles_count' => 10,
|
||||
'newsapi_key' => '',
|
||||
'newsapi_enabled' => 0,
|
||||
'rss_feeds' => implode( "\n", array(
|
||||
'https://news.google.com/rss/search?q=peptide+research&hl=en-US&gl=US&ceid=US:en',
|
||||
'https://pubmed.ncbi.nlm.nih.gov/rss/search/1sUlVGjEXm1YN9QiGPblv_-bwB51JCtyLwVX85CVGDKCHFFR9Q/?limit=20&utm_campaign=pubmed-2&fc=20220101000000',
|
||||
) ),
|
||||
'rss_enabled' => 1,
|
||||
'search_keywords' => 'peptide, peptides, peptide therapy, peptide research, BPC-157, Thymosin, GHK-Cu',
|
||||
'thumbnail_fallback' => PEPTIDE_NEWS_PLUGIN_URL . 'public/images/default-thumb.png',
|
||||
'article_retention' => 90,
|
||||
'analytics_retention' => 365,
|
||||
'anonymize_ip' => 1,
|
||||
'llm_enabled' => 0,
|
||||
'openrouter_api_key' => '',
|
||||
'llm_keywords_model' => 'google/gemini-2.0-flash-001',
|
||||
'llm_summary_model' => 'google/gemini-2.0-flash-001',
|
||||
'llm_max_articles' => 10,
|
||||
);
|
||||
/**
|
||||
* Set sensible default option values.
|
||||
*/
|
||||
private static function set_default_options() {
|
||||
$defaults = array(
|
||||
'fetch_interval' => 'twicedaily',
|
||||
'articles_count' => 10,
|
||||
'newsapi_key' => '',
|
||||
'newsapi_enabled' => 0,
|
||||
'rss_feeds' => implode( "\n", array(
|
||||
'https://news.google.com/rss/search?q=peptide+research&hl=en-US&gl=US&ceid=US:en',
|
||||
'https://pubmed.ncbi.nlm.nih.gov/rss/search/1sUlVGjEXm1YN9QiGPblv_-bwB51JCtyLwVX85CVGDKCHFFR9Q/?limit=20&utm_campaign=pubmed-2&fc=20220101000000',
|
||||
) ),
|
||||
'rss_enabled' => 1,
|
||||
'search_keywords' => 'peptide, peptides, peptide therapy, peptide research, BPC-157, Thymosin, GHK-Cu',
|
||||
'thumbnail_fallback' => PEPTIDE_NEWS_PLUGIN_URL . 'public/images/default-thumb.png',
|
||||
'article_retention' => 90,
|
||||
'analytics_retention' => 365,
|
||||
'anonymize_ip' => 1,
|
||||
'llm_enabled' => 0,
|
||||
'openrouter_api_key' => '',
|
||||
'llm_keywords_model' => 'google/gemini-2.0-flash-001',
|
||||
'llm_summary_model' => 'google/gemini-2.0-flash-001',
|
||||
'llm_max_articles' => 10,
|
||||
);
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
if ( false === get_option( "peptide_news_{$key}" ) ) {
|
||||
add_option( "peptide_news_{$key}", $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
if ( false === get_option( "peptide_news_{$key}" ) ) {
|
||||
add_option( "peptide_news_{$key}", $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the cron event based on the configured interval.
|
||||
*/
|
||||
private static function schedule_cron() {
|
||||
$interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' );
|
||||
/**
|
||||
* Schedule the cron event based on the configured interval.
|
||||
*/
|
||||
private static function schedule_cron() {
|
||||
$interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' );
|
||||
|
||||
if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) {
|
||||
wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' );
|
||||
}
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) {
|
||||
wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add foreign key constraints to tables.
|
||||
*/
|
||||
private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Add foreign key constraints to tables.
|
||||
*/
|
||||
private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$existing_clicks_fk = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1",
|
||||
$clicks_table
|
||||
)
|
||||
);
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$existing_clicks_fk = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1",
|
||||
$clicks_table
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $existing_clicks_fk ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query(
|
||||
"ALTER TABLE `{$clicks_table}` ADD CONSTRAINT `fk_clicks_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE"
|
||||
);
|
||||
}
|
||||
if ( empty( $existing_clicks_fk ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query(
|
||||
"ALTER TABLE `{$clicks_table}` ADD CONSTRAINT `fk_clicks_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE"
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$existing_stats_fk = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1",
|
||||
$daily_table
|
||||
)
|
||||
);
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$existing_stats_fk = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1",
|
||||
$daily_table
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $existing_stats_fk ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query(
|
||||
"ALTER TABLE `{$daily_table}` ADD CONSTRAINT `fk_stats_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE"
|
||||
);
|
||||
}
|
||||
}
|
||||
if ( empty( $existing_stats_fk ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query(
|
||||
"ALTER TABLE `{$daily_table}` ADD CONSTRAINT `fk_stats_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,342 +9,342 @@
|
||||
*/
|
||||
class Peptide_News_Analytics {
|
||||
|
||||
/**
|
||||
* Record a click event for an article.
|
||||
*
|
||||
* @param int $article_id
|
||||
* @param array $meta Additional context (IP, UA, referrer, etc.).
|
||||
* @return bool
|
||||
*/
|
||||
public static function record_click( $article_id, $meta = array() ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Record a click event for an article.
|
||||
*
|
||||
* @param int $article_id
|
||||
* @param array $meta Additional context (IP, UA, referrer, etc.).
|
||||
* @return bool
|
||||
*/
|
||||
public static function record_click( $article_id, $meta = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
$table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
|
||||
$ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip();
|
||||
$ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip();
|
||||
|
||||
// Skip recording if no valid IP is available.
|
||||
if ( null === $ip ) {
|
||||
return false;
|
||||
}
|
||||
// Skip recording if no valid IP is available.
|
||||
if ( null === $ip ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optionally anonymize IP (GDPR-friendly).
|
||||
if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) {
|
||||
$ip = self::anonymize_ip( $ip );
|
||||
}
|
||||
// Optionally anonymize IP (GDPR-friendly).
|
||||
if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) {
|
||||
$ip = self::anonymize_ip( $ip );
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'article_id' => absint( $article_id ),
|
||||
'clicked_at' => current_time( 'mysql' ),
|
||||
'user_ip' => sanitize_text_field( $ip ),
|
||||
'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ),
|
||||
'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ),
|
||||
'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ),
|
||||
'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ),
|
||||
'user_id' => is_user_logged_in() ? get_current_user_id() : null,
|
||||
'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ),
|
||||
);
|
||||
$data = array(
|
||||
'article_id' => absint( $article_id ),
|
||||
'clicked_at' => current_time( 'mysql' ),
|
||||
'user_ip' => sanitize_text_field( $ip ),
|
||||
'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ),
|
||||
'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ),
|
||||
'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ),
|
||||
'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ),
|
||||
'user_id' => is_user_logged_in() ? get_current_user_id() : null,
|
||||
'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ),
|
||||
);
|
||||
|
||||
$result = $wpdb->insert( $table, $data );
|
||||
$result = $wpdb->insert( $table, $data );
|
||||
|
||||
// Update daily aggregate.
|
||||
if ( false !== $result ) {
|
||||
self::update_daily_stats( $article_id, $ip );
|
||||
}
|
||||
// Update daily aggregate.
|
||||
if ( false !== $result ) {
|
||||
self::update_daily_stats( $article_id, $ip );
|
||||
}
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update daily aggregated stats.
|
||||
*
|
||||
* @param int $article_id
|
||||
* @param string $ip Anonymized IP for unique visitor counting.
|
||||
*/
|
||||
private static function update_daily_stats( $article_id, $ip ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Update daily aggregated stats.
|
||||
*
|
||||
* @param int $article_id
|
||||
* @param string $ip Anonymized IP for unique visitor counting.
|
||||
*/
|
||||
private static function update_daily_stats( $article_id, $ip ) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$today = current_time( 'Y-m-d' );
|
||||
$clicks_tbl = $wpdb->prefix . 'peptide_news_clicks';
|
||||
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$today = current_time( 'Y-m-d' );
|
||||
$clicks_tbl = $wpdb->prefix . 'peptide_news_clicks';
|
||||
|
||||
// Upsert daily click count.
|
||||
$existing = $wpdb->get_row( $wpdb->prepare(
|
||||
"SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s",
|
||||
$article_id,
|
||||
$today
|
||||
) );
|
||||
// Upsert daily click count.
|
||||
$existing = $wpdb->get_row( $wpdb->prepare(
|
||||
"SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s",
|
||||
$article_id,
|
||||
$today
|
||||
) );
|
||||
|
||||
// Count unique IPs for today.
|
||||
$unique = $wpdb->get_var( $wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s",
|
||||
$article_id,
|
||||
$today
|
||||
) );
|
||||
// Count unique IPs for today.
|
||||
$unique = $wpdb->get_var( $wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s",
|
||||
$article_id,
|
||||
$today
|
||||
) );
|
||||
|
||||
if ( $existing ) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'click_count' => $existing->click_count + 1,
|
||||
'unique_visitors' => (int) $unique,
|
||||
),
|
||||
array( 'id' => $existing->id ),
|
||||
array( '%d', '%d' ),
|
||||
array( '%d' )
|
||||
);
|
||||
} else {
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'article_id' => $article_id,
|
||||
'stat_date' => $today,
|
||||
'click_count' => 1,
|
||||
'unique_visitors' => 1,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if ( $existing ) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'click_count' => $existing->click_count + 1,
|
||||
'unique_visitors' => (int) $unique,
|
||||
),
|
||||
array( 'id' => $existing->id ),
|
||||
array( '%d', '%d' ),
|
||||
array( '%d' )
|
||||
);
|
||||
} else {
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'article_id' => $article_id,
|
||||
'stat_date' => $today,
|
||||
'click_count' => 1,
|
||||
'unique_visitors' => 1,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top articles by click count within a date range.
|
||||
*
|
||||
* @param string $start_date Y-m-d
|
||||
* @param string $end_date Y-m-d
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public static function get_top_articles( $start_date, $end_date, $limit = 20 ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Get top articles by click count within a date range.
|
||||
*
|
||||
* @param string $start_date Y-m-d
|
||||
* @param string $end_date Y-m-d
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public static function get_top_articles( $start_date, $end_date, $limit = 20 ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at,
|
||||
SUM(s.click_count) AS total_clicks,
|
||||
SUM(s.unique_visitors) AS total_unique
|
||||
FROM {$stats_table} s
|
||||
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
||||
WHERE s.stat_date BETWEEN %s AND %s
|
||||
GROUP BY a.id
|
||||
ORDER BY total_clicks DESC
|
||||
LIMIT %d",
|
||||
$start_date,
|
||||
$end_date,
|
||||
$limit
|
||||
) );
|
||||
}
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at,
|
||||
SUM(s.click_count) AS total_clicks,
|
||||
SUM(s.unique_visitors) AS total_unique
|
||||
FROM {$stats_table} s
|
||||
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
||||
WHERE s.stat_date BETWEEN %s AND %s
|
||||
GROUP BY a.id
|
||||
ORDER BY total_clicks DESC
|
||||
LIMIT %d",
|
||||
$start_date,
|
||||
$end_date,
|
||||
$limit
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get click trends over time (daily totals).
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_click_trends( $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Get click trends over time (daily totals).
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_click_trends( $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique
|
||||
FROM {$table}
|
||||
WHERE stat_date BETWEEN %s AND %s
|
||||
GROUP BY stat_date
|
||||
ORDER BY stat_date ASC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
}
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique
|
||||
FROM {$table}
|
||||
WHERE stat_date BETWEEN %s AND %s
|
||||
GROUP BY stat_date
|
||||
ORDER BY stat_date ASC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular topics/categories by click volume.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Get popular topics/categories by click volume.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
// Get articles with their click counts.
|
||||
$rows = $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT a.categories, a.title, SUM(s.click_count) AS clicks
|
||||
FROM {$stats_table} s
|
||||
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
||||
WHERE s.stat_date BETWEEN %s AND %s AND a.categories != ''
|
||||
GROUP BY a.id
|
||||
ORDER BY clicks DESC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
// Get articles with their click counts.
|
||||
$rows = $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT a.categories, a.title, SUM(s.click_count) AS clicks
|
||||
FROM {$stats_table} s
|
||||
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
||||
WHERE s.stat_date BETWEEN %s AND %s AND a.categories != ''
|
||||
GROUP BY a.id
|
||||
ORDER BY clicks DESC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
|
||||
// Aggregate by category.
|
||||
$topics = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$cats = array_map( 'trim', explode( ',', $row->categories ) );
|
||||
foreach ( $cats as $cat ) {
|
||||
if ( empty( $cat ) ) {
|
||||
continue;
|
||||
}
|
||||
$cat_lower = strtolower( $cat );
|
||||
if ( ! isset( $topics[ $cat_lower ] ) ) {
|
||||
$topics[ $cat_lower ] = array(
|
||||
'topic' => $cat,
|
||||
'total_clicks' => 0,
|
||||
'article_count' => 0,
|
||||
);
|
||||
}
|
||||
$topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks;
|
||||
$topics[ $cat_lower ]['article_count'] += 1;
|
||||
}
|
||||
}
|
||||
// Aggregate by category.
|
||||
$topics = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$cats = array_map( 'trim', explode( ',', $row->categories ) );
|
||||
foreach ( $cats as $cat ) {
|
||||
if ( empty( $cat ) ) {
|
||||
continue;
|
||||
}
|
||||
$cat_lower = strtolower( $cat );
|
||||
if ( ! isset( $topics[ $cat_lower ] ) ) {
|
||||
$topics[ $cat_lower ] = array(
|
||||
'topic' => $cat,
|
||||
'total_clicks' => 0,
|
||||
'article_count' => 0,
|
||||
);
|
||||
}
|
||||
$topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks;
|
||||
$topics[ $cat_lower ]['article_count'] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by clicks.
|
||||
usort( $topics, function ( $a, $b ) {
|
||||
return $b['total_clicks'] - $a['total_clicks'];
|
||||
} );
|
||||
// Sort by clicks.
|
||||
usort( $topics, function ( $a, $b ) {
|
||||
return $b['total_clicks'] - $a['total_clicks'];
|
||||
} );
|
||||
|
||||
return array_slice( $topics, 0, $limit );
|
||||
}
|
||||
return array_slice( $topics, 0, $limit );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device breakdown stats.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_device_breakdown( $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Get device breakdown stats.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_device_breakdown( $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
$table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT device_type, COUNT(*) AS click_count
|
||||
FROM {$table}
|
||||
WHERE DATE(clicked_at) BETWEEN %s AND %s
|
||||
GROUP BY device_type
|
||||
ORDER BY click_count DESC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
}
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT device_type, COUNT(*) AS click_count
|
||||
FROM {$table}
|
||||
WHERE DATE(clicked_at) BETWEEN %s AND %s
|
||||
GROUP BY device_type
|
||||
ORDER BY click_count DESC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source performance stats.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_source_performance( $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Get source performance stats.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_source_performance( $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT a.source,
|
||||
COUNT(DISTINCT a.id) AS article_count,
|
||||
SUM(s.click_count) AS total_clicks,
|
||||
ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article
|
||||
FROM {$stats_table} s
|
||||
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
||||
WHERE s.stat_date BETWEEN %s AND %s
|
||||
GROUP BY a.source
|
||||
ORDER BY total_clicks DESC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
}
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT a.source,
|
||||
COUNT(DISTINCT a.id) AS article_count,
|
||||
SUM(s.click_count) AS total_clicks,
|
||||
ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article
|
||||
FROM {$stats_table} s
|
||||
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
||||
WHERE s.stat_date BETWEEN %s AND %s
|
||||
GROUP BY a.source
|
||||
ORDER BY total_clicks DESC",
|
||||
$start_date,
|
||||
$end_date
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Export click data as CSV-ready array.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function export_clicks_csv( $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Export click data as CSV-ready array.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function export_clicks_csv( $start_date, $end_date ) {
|
||||
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",
|
||||
$start_date,
|
||||
$end_date
|
||||
), ARRAY_A );
|
||||
}
|
||||
$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",
|
||||
$start_date,
|
||||
$end_date
|
||||
), ARRAY_A );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client IP address.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
private static function get_client_ip() {
|
||||
$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
|
||||
/**
|
||||
* Get the client IP address.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
private static function get_client_ip() {
|
||||
$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
|
||||
|
||||
foreach ( $ip_keys as $key ) {
|
||||
if ( ! empty( $_SERVER[ $key ] ) ) {
|
||||
$ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
|
||||
$ip = trim( $ip[0] );
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ( $ip_keys as $key ) {
|
||||
if ( ! empty( $_SERVER[ $key ] ) ) {
|
||||
$ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
|
||||
$ip = trim( $ip[0] );
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6).
|
||||
*
|
||||
* @param string $ip
|
||||
* @return string
|
||||
*/
|
||||
private static function anonymize_ip( $ip ) {
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
|
||||
return preg_replace( '/\.\d+$/', '.0', $ip );
|
||||
}
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
||||
return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000';
|
||||
}
|
||||
return '0.0.0.0';
|
||||
}
|
||||
/**
|
||||
* Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6).
|
||||
*
|
||||
* @param string $ip
|
||||
* @return string
|
||||
*/
|
||||
private static function anonymize_ip( $ip ) {
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
|
||||
return preg_replace( '/\.\d+$/', '.0', $ip );
|
||||
}
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
||||
return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000';
|
||||
}
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple device detection from user agent.
|
||||
*
|
||||
* @param string $ua
|
||||
* @return string
|
||||
*/
|
||||
private static function detect_device( $ua ) {
|
||||
$ua = strtolower( $ua );
|
||||
/**
|
||||
* Simple device detection from user agent.
|
||||
*
|
||||
* @param string $ua
|
||||
* @return string
|
||||
*/
|
||||
private static function detect_device( $ua ) {
|
||||
$ua = strtolower( $ua );
|
||||
|
||||
if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) {
|
||||
return 'tablet';
|
||||
}
|
||||
if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) {
|
||||
return 'mobile';
|
||||
}
|
||||
if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) {
|
||||
return 'bot';
|
||||
}
|
||||
if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) {
|
||||
return 'tablet';
|
||||
}
|
||||
if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) {
|
||||
return 'mobile';
|
||||
}
|
||||
if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) {
|
||||
return 'bot';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
return 'desktop';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,8 +398,7 @@ class Peptide_News_Content_Filter {
|
||||
|
||||
// Use XML-style delimiters to isolate untrusted article data from the prompt.
|
||||
// This mitigates prompt injection by clearly separating instructions from data.
|
||||
return
|
||||
"You are a content classifier. Your ONLY task is to classify the article below.\n" .
|
||||
$prompt = "You are a content classifier. Your ONLY task is to classify the article below.\n" .
|
||||
"Respond with EXACTLY one word: EDITORIAL or PROMOTIONAL.\n" .
|
||||
"EDITORIAL = genuine news, research, or journalism.\n" .
|
||||
"PROMOTIONAL = press releases, ads, sponsored content, product launches, " .
|
||||
@@ -412,6 +411,7 @@ class Peptide_News_Content_Filter {
|
||||
sprintf( "<content>%s</content>\n", $content_trimmed ) .
|
||||
"</article_to_classify>\n\n" .
|
||||
"Classification (one word only):";
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
class Peptide_News_Deactivator {
|
||||
|
||||
public static function deactivate() {
|
||||
wp_clear_scheduled_hook( 'peptide_news_cron_fetch' );
|
||||
}
|
||||
public static function deactivate() {
|
||||
wp_clear_scheduled_hook( 'peptide_news_cron_fetch' );
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,109 +12,109 @@
|
||||
|
||||
class Peptide_News_Loader {
|
||||
|
||||
/**
|
||||
* The array of actions registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @access protected
|
||||
* @var array $actions The actions registered with WordPress to fire when the plugin loads.
|
||||
*/
|
||||
protected $actions;
|
||||
/**
|
||||
* The array of actions registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @access protected
|
||||
* @var array $actions The actions registered with WordPress to fire when the plugin loads.
|
||||
*/
|
||||
protected $actions;
|
||||
|
||||
/**
|
||||
* The array of filters registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @access protected
|
||||
* @var array $filters The filters registered with WordPress.
|
||||
*/
|
||||
protected $filters;
|
||||
/**
|
||||
* The array of filters registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @access protected
|
||||
* @var array $filters The filters registered with WordPress.
|
||||
*/
|
||||
protected $filters;
|
||||
|
||||
/**
|
||||
* Initialize the collections used to maintain the actions and filters.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->actions = array();
|
||||
$this->filters = array();
|
||||
}
|
||||
/**
|
||||
* Initialize the collections used to maintain the actions and filters.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->actions = array();
|
||||
$this->filters = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new action to the collection to be registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @param string $hook The name of the WordPress action that is being registered.
|
||||
* @param object $component A reference to the instance of the object on which the action is defined.
|
||||
* @param string $callback The name of the function definition on the $component.
|
||||
* @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_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
|
||||
$this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args );
|
||||
}
|
||||
/**
|
||||
* Add a new action to the collection to be registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @param string $hook The name of the WordPress action that is being registered.
|
||||
* @param object $component A reference to the instance of the object on which the action is defined.
|
||||
* @param string $callback The name of the function definition on the $component.
|
||||
* @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_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
|
||||
$this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new filter to the collection to be registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @param string $hook The name of the WordPress filter that is being registered.
|
||||
* @param object $component A reference to the instance of the object on which the filter is defined.
|
||||
* @param string $callback The name of the function definition on the $component.
|
||||
* @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 ) {
|
||||
$this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args );
|
||||
}
|
||||
/**
|
||||
* Add a new filter to the collection to be registered with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @param string $hook The name of the WordPress filter that is being registered.
|
||||
* @param object $component A reference to the instance of the object on which the filter is defined.
|
||||
* @param string $callback The name of the function definition on the $component.
|
||||
* @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 ) {
|
||||
$this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args );
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility function that is used to register the actions and hooks into a single collection.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @access private
|
||||
* @param array $hooks The collection of hooks that is being registered (actions or filters).
|
||||
* @param string $hook The name of the WordPress filter that is being registered.
|
||||
* @param object $component A reference to the instance of the object on which the filter is defined.
|
||||
* @param string $callback The name of the function definition on the $component.
|
||||
* @param int $priority The priority at which the function should be fired.
|
||||
* @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 ) {
|
||||
$hooks[] = array(
|
||||
'hook' => $hook,
|
||||
'component' => $component,
|
||||
'callback' => $callback,
|
||||
'priority' => $priority,
|
||||
'accepted_args' => $accepted_args,
|
||||
);
|
||||
/**
|
||||
* A utility function that is used to register the actions and hooks into a single collection.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @access private
|
||||
* @param array $hooks The collection of hooks that is being registered (actions or filters).
|
||||
* @param string $hook The name of the WordPress filter that is being registered.
|
||||
* @param object $component A reference to the instance of the object on which the filter is defined.
|
||||
* @param string $callback The name of the function definition on the $component.
|
||||
* @param int $priority The priority at which the function should be fired.
|
||||
* @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 ) {
|
||||
$hooks[] = array(
|
||||
'hook' => $hook,
|
||||
'component' => $component,
|
||||
'callback' => $callback,
|
||||
'priority' => $priority,
|
||||
'accepted_args' => $accepted_args,
|
||||
);
|
||||
|
||||
return $hooks;
|
||||
}
|
||||
return $hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the filters and actions with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function run() {
|
||||
foreach ( $this->filters as $hook ) {
|
||||
add_filter(
|
||||
$hook['hook'],
|
||||
array( $hook['component'], $hook['callback'] ),
|
||||
$hook['priority'],
|
||||
$hook['accepted_args']
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Register the filters and actions with WordPress.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function run() {
|
||||
foreach ( $this->filters as $hook ) {
|
||||
add_filter(
|
||||
$hook['hook'],
|
||||
array( $hook['component'], $hook['callback'] ),
|
||||
$hook['priority'],
|
||||
$hook['accepted_args']
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $this->actions as $hook ) {
|
||||
add_action(
|
||||
$hook['hook'],
|
||||
array( $hook['component'], $hook['callback'] ),
|
||||
$hook['priority'],
|
||||
$hook['accepted_args']
|
||||
);
|
||||
}
|
||||
}
|
||||
foreach ( $this->actions as $hook ) {
|
||||
add_action(
|
||||
$hook['hook'],
|
||||
array( $hook['component'], $hook['callback'] ),
|
||||
$hook['priority'],
|
||||
$hook['accepted_args']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,246 +9,246 @@
|
||||
*/
|
||||
class Peptide_News_Logger {
|
||||
|
||||
/** Log level constants. */
|
||||
const LEVEL_INFO = 'info';
|
||||
const LEVEL_WARNING = 'warning';
|
||||
const LEVEL_ERROR = 'error';
|
||||
const LEVEL_DEBUG = 'debug';
|
||||
/** Log level constants. */
|
||||
const LEVEL_INFO = 'info';
|
||||
const LEVEL_WARNING = 'warning';
|
||||
const LEVEL_ERROR = 'error';
|
||||
const LEVEL_DEBUG = 'debug';
|
||||
|
||||
/** Maximum rows kept in the log table (auto-pruned on write). */
|
||||
const MAX_ROWS = 2000;
|
||||
/** Maximum rows kept in the log table (auto-pruned on write). */
|
||||
const MAX_ROWS = 2000;
|
||||
|
||||
/**
|
||||
* Return the log table name.
|
||||
*/
|
||||
public static function table() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'peptide_news_log';
|
||||
}
|
||||
/**
|
||||
* Return the log table name.
|
||||
*/
|
||||
public static function table() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'peptide_news_log';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Writing
|
||||
* ----------------------------------------------------------------*/
|
||||
/* ------------------------------------------------------------------
|
||||
* Writing
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Write a log entry.
|
||||
*
|
||||
* @param string $level One of the LEVEL_* constants.
|
||||
* @param string $message Human-readable message.
|
||||
* @param string $context Category tag (fetch, llm, admin, cron, general).
|
||||
*/
|
||||
public static function log( $level, $message, $context = 'general' ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Write a log entry.
|
||||
*
|
||||
* @param string $level One of the LEVEL_* constants.
|
||||
* @param string $message Human-readable message.
|
||||
* @param string $context Category tag (fetch, llm, admin, cron, general).
|
||||
*/
|
||||
public static function log( $level, $message, $context = 'general' ) {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->insert(
|
||||
self::table(),
|
||||
array(
|
||||
'level' => $level,
|
||||
'context' => $context,
|
||||
'message' => $message,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
$wpdb->insert(
|
||||
self::table(),
|
||||
array(
|
||||
'level' => $level,
|
||||
'context' => $context,
|
||||
'message' => $message,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
// Auto-prune old entries.
|
||||
self::maybe_prune();
|
||||
}
|
||||
// Auto-prune old entries.
|
||||
self::maybe_prune();
|
||||
}
|
||||
|
||||
/** Convenience: info level. */
|
||||
public static function info( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_INFO, $message, $context );
|
||||
}
|
||||
/** Convenience: info level. */
|
||||
public static function info( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_INFO, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: warning level. */
|
||||
public static function warning( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_WARNING, $message, $context );
|
||||
}
|
||||
/** Convenience: warning level. */
|
||||
public static function warning( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_WARNING, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: error level. */
|
||||
public static function error( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_ERROR, $message, $context );
|
||||
}
|
||||
/** Convenience: error level. */
|
||||
public static function error( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_ERROR, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: debug level (only writes when WP_DEBUG is on). */
|
||||
public static function debug( $message, $context = 'general' ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
self::log( self::LEVEL_DEBUG, $message, $context );
|
||||
}
|
||||
}
|
||||
/** Convenience: debug level (only writes when WP_DEBUG is on). */
|
||||
public static function debug( $message, $context = 'general' ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
self::log( self::LEVEL_DEBUG, $message, $context );
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Reading
|
||||
* ----------------------------------------------------------------*/
|
||||
/* ------------------------------------------------------------------
|
||||
* Reading
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Retrieve log entries.
|
||||
*
|
||||
* @param array $args {
|
||||
* Optional. Query arguments.
|
||||
*
|
||||
* @type string $level Filter by level.
|
||||
* @type string $context Filter by context.
|
||||
* @type int $limit Number of rows.
|
||||
* @type int $offset Pagination offset.
|
||||
* }
|
||||
* @return array { 'rows' => array, 'total' => int }
|
||||
*/
|
||||
public static function get_logs( $args = array() ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Retrieve log entries.
|
||||
*
|
||||
* @param array $args {
|
||||
* Optional. Query arguments.
|
||||
*
|
||||
* @type string $level Filter by level.
|
||||
* @type string $context Filter by context.
|
||||
* @type int $limit Number of rows.
|
||||
* @type int $offset Pagination offset.
|
||||
* }
|
||||
* @return array { 'rows' => array, 'total' => int }
|
||||
*/
|
||||
public static function get_logs( $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'level' => '',
|
||||
'context' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
);
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
$defaults = array(
|
||||
'level' => '',
|
||||
'context' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
);
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$table = self::table();
|
||||
$where = array( '1=1' );
|
||||
$vals = array();
|
||||
$table = self::table();
|
||||
$where = array( '1=1' );
|
||||
$vals = array();
|
||||
|
||||
if ( ! empty( $args['level'] ) ) {
|
||||
$where[] = 'level = %s';
|
||||
$vals[] = $args['level'];
|
||||
}
|
||||
if ( ! empty( $args['context'] ) ) {
|
||||
$where[] = 'context = %s';
|
||||
$vals[] = $args['context'];
|
||||
}
|
||||
if ( ! empty( $args['level'] ) ) {
|
||||
$where[] = 'level = %s';
|
||||
$vals[] = $args['level'];
|
||||
}
|
||||
if ( ! empty( $args['context'] ) ) {
|
||||
$where[] = 'context = %s';
|
||||
$vals[] = $args['context'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where );
|
||||
$where_sql = implode( ' AND ', $where );
|
||||
|
||||
// Total count.
|
||||
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
$total = empty( $vals )
|
||||
? (int) $wpdb->get_var( $count_sql )
|
||||
: (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) );
|
||||
// Total count.
|
||||
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
$total = empty( $vals )
|
||||
? (int) $wpdb->get_var( $count_sql )
|
||||
: (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) );
|
||||
|
||||
// Rows.
|
||||
$order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d";
|
||||
$vals[] = absint( $args['limit'] );
|
||||
$vals[] = absint( $args['offset'] );
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) );
|
||||
// Rows.
|
||||
$order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d";
|
||||
$vals[] = absint( $args['limit'] );
|
||||
$vals[] = absint( $args['offset'] );
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) );
|
||||
|
||||
return array(
|
||||
'rows' => $rows ? $rows : array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
return array(
|
||||
'rows' => $rows ? $rows : array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Maintenance
|
||||
* ----------------------------------------------------------------*/
|
||||
/* ------------------------------------------------------------------
|
||||
* Maintenance
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Remove rows beyond the MAX_ROWS cap.
|
||||
*/
|
||||
private static function maybe_prune() {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
/**
|
||||
* Remove rows beyond the MAX_ROWS cap.
|
||||
*/
|
||||
private static function maybe_prune() {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
|
||||
// Only prune every ~50 writes (probabilistic).
|
||||
if ( wp_rand( 1, 50 ) !== 1 ) {
|
||||
return;
|
||||
}
|
||||
// Only prune every ~50 writes (probabilistic).
|
||||
if ( wp_rand( 1, 50 ) !== 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
|
||||
|
||||
if ( $count > self::MAX_ROWS ) {
|
||||
$delete_count = $count - self::MAX_ROWS;
|
||||
$wpdb->query( $wpdb->prepare(
|
||||
"DELETE FROM {$table} ORDER BY id ASC LIMIT %d",
|
||||
$delete_count
|
||||
) );
|
||||
}
|
||||
}
|
||||
if ( $count > self::MAX_ROWS ) {
|
||||
$delete_count = $count - self::MAX_ROWS;
|
||||
$wpdb->query( $wpdb->prepare(
|
||||
"DELETE FROM {$table} ORDER BY id ASC LIMIT %d",
|
||||
$delete_count
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all log entries.
|
||||
*/
|
||||
public static function clear() {
|
||||
global $wpdb;
|
||||
$wpdb->query( "TRUNCATE TABLE " . self::table() );
|
||||
}
|
||||
/**
|
||||
* Clear all log entries.
|
||||
*/
|
||||
public static function clear() {
|
||||
global $wpdb;
|
||||
$wpdb->query( "TRUNCATE TABLE " . self::table() );
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* AJAX handlers
|
||||
* ----------------------------------------------------------------*/
|
||||
/* ------------------------------------------------------------------
|
||||
* AJAX handlers
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* AJAX: fetch paginated log entries.
|
||||
*/
|
||||
public static function ajax_get_logs() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
/**
|
||||
* AJAX: fetch paginated log entries.
|
||||
*/
|
||||
public static function ajax_get_logs() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized', 403 );
|
||||
}
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized', 403 );
|
||||
}
|
||||
|
||||
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
|
||||
$level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : '';
|
||||
$context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : '';
|
||||
$per_page = 50;
|
||||
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
|
||||
$level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : '';
|
||||
$context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : '';
|
||||
$per_page = 50;
|
||||
|
||||
$result = self::get_logs( array(
|
||||
'level' => $level,
|
||||
'context' => $context,
|
||||
'limit' => $per_page,
|
||||
'offset' => ( $page - 1 ) * $per_page,
|
||||
) );
|
||||
$result = self::get_logs( array(
|
||||
'level' => $level,
|
||||
'context' => $context,
|
||||
'limit' => $per_page,
|
||||
'offset' => ( $page - 1 ) * $per_page,
|
||||
) );
|
||||
|
||||
wp_send_json_success( array(
|
||||
'rows' => $result['rows'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'total_pages' => ceil( $result['total'] / $per_page ),
|
||||
) );
|
||||
}
|
||||
wp_send_json_success( array(
|
||||
'rows' => $result['rows'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'total_pages' => ceil( $result['total'] / $per_page ),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: clear all log entries.
|
||||
*/
|
||||
public static function ajax_clear_logs() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
/**
|
||||
* AJAX: clear all log entries.
|
||||
*/
|
||||
public static function ajax_clear_logs() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized', 403 );
|
||||
}
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized', 403 );
|
||||
}
|
||||
|
||||
self::clear();
|
||||
self::info( 'Log cleared by admin.', 'admin' );
|
||||
self::clear();
|
||||
self::info( 'Log cleared by admin.', 'admin' );
|
||||
|
||||
wp_send_json_success( array( 'message' => 'Log cleared.' ) );
|
||||
}
|
||||
wp_send_json_success( array( 'message' => 'Log cleared.' ) );
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Schema
|
||||
* ----------------------------------------------------------------*/
|
||||
/* ------------------------------------------------------------------
|
||||
* Schema
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Create the log table (called from activator / upgrade check).
|
||||
*/
|
||||
public static function create_table() {
|
||||
global $wpdb;
|
||||
/**
|
||||
* Create the log table (called from activator / upgrade check).
|
||||
*/
|
||||
public static function create_table() {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table();
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$table = self::table();
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||
context VARCHAR(30) NOT NULL DEFAULT 'general',
|
||||
message TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_level (level),
|
||||
KEY idx_context (context),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||
context VARCHAR(30) NOT NULL DEFAULT 'general',
|
||||
message TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_level (level),
|
||||
KEY idx_context (context),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
}
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,418 +17,418 @@
|
||||
*/
|
||||
class Peptide_News_Rest_API {
|
||||
|
||||
const API_NAMESPACE = 'peptide-news/v1';
|
||||
const API_NAMESPACE = 'peptide-news/v1';
|
||||
|
||||
/**
|
||||
* Register REST routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
/**
|
||||
* Register REST routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
|
||||
// Public: list articles.
|
||||
register_rest_route( self::API_NAMESPACE, '/articles', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( $this, 'get_articles' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'count' => array(
|
||||
'default' => 10,
|
||||
'sanitize_callback' => 'absint',
|
||||
),
|
||||
'page' => array(
|
||||
'default' => 1,
|
||||
'sanitize_callback' => 'absint',
|
||||
),
|
||||
),
|
||||
) );
|
||||
// Public: list articles.
|
||||
register_rest_route( self::API_NAMESPACE, '/articles', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( $this, 'get_articles' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'count' => array(
|
||||
'default' => 10,
|
||||
'sanitize_callback' => 'absint',
|
||||
),
|
||||
'page' => array(
|
||||
'default' => 1,
|
||||
'sanitize_callback' => 'absint',
|
||||
),
|
||||
),
|
||||
) );
|
||||
|
||||
// Admin-only analytics endpoints.
|
||||
$analytics_routes = array(
|
||||
'/analytics/top' => 'get_top_articles',
|
||||
'/analytics/trends' => 'get_trends',
|
||||
'/analytics/topics' => 'get_topics',
|
||||
'/analytics/devices' => 'get_devices',
|
||||
'/analytics/sources' => 'get_sources',
|
||||
'/analytics/export' => 'get_export',
|
||||
);
|
||||
// Admin-only analytics endpoints.
|
||||
$analytics_routes = array(
|
||||
'/analytics/top' => 'get_top_articles',
|
||||
'/analytics/trends' => 'get_trends',
|
||||
'/analytics/topics' => 'get_topics',
|
||||
'/analytics/devices' => 'get_devices',
|
||||
'/analytics/sources' => 'get_sources',
|
||||
'/analytics/export' => 'get_export',
|
||||
);
|
||||
|
||||
foreach ( $analytics_routes as $route => $callback ) {
|
||||
register_rest_route( self::API_NAMESPACE, $route, array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( $this, $callback ),
|
||||
'permission_callback' => array( $this, 'check_admin_permissions' ),
|
||||
'args' => $this->get_date_range_args(),
|
||||
) );
|
||||
}
|
||||
}
|
||||
foreach ( $analytics_routes as $route => $callback ) {
|
||||
register_rest_route( self::API_NAMESPACE, $route, array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( $this, $callback ),
|
||||
'permission_callback' => array( $this, 'check_admin_permissions' ),
|
||||
'args' => $this->get_date_range_args(),
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the request comes from an admin user.
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public function check_admin_permissions( $request ) {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return new WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'You do not have permission to access analytics data.', 'peptide-news' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Verify the request comes from an admin user.
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public function check_admin_permissions( $request ) {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return new WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'You do not have permission to access analytics data.', 'peptide-news' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common date-range argument definitions.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_date_range_args() {
|
||||
return array(
|
||||
'start_date' => array(
|
||||
'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => array( $this, 'validate_date_format' ),
|
||||
),
|
||||
'end_date' => array(
|
||||
'default' => gmdate( 'Y-m-d' ),
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => array( $this, 'validate_date_format' ),
|
||||
),
|
||||
'limit' => array(
|
||||
'default' => 20,
|
||||
'sanitize_callback' => 'absint',
|
||||
),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Common date-range argument definitions.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_date_range_args() {
|
||||
return array(
|
||||
'start_date' => array(
|
||||
'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => array( $this, 'validate_date_format' ),
|
||||
),
|
||||
'end_date' => array(
|
||||
'default' => gmdate( 'Y-m-d' ),
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => array( $this, 'validate_date_format' ),
|
||||
),
|
||||
'limit' => array(
|
||||
'default' => 20,
|
||||
'sanitize_callback' => 'absint',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a parameter is a Y-m-d date string.
|
||||
*
|
||||
* @param string $value
|
||||
* @param WP_REST_Request $request
|
||||
* @param string $param
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function validate_date_format( $value, $request, $param ) {
|
||||
if ( ! preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $value ) ) {
|
||||
return new WP_Error(
|
||||
'rest_invalid_date',
|
||||
sprintf( __( 'Invalid date format for %s. Expected Y-m-d.', 'peptide-news' ), $param ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Validate that a parameter is a Y-m-d date string.
|
||||
*
|
||||
* @param string $value
|
||||
* @param WP_REST_Request $request
|
||||
* @param string $param
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function validate_date_format( $value, $request, $param ) {
|
||||
if ( ! preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $value ) ) {
|
||||
return new WP_Error(
|
||||
'rest_invalid_date',
|
||||
/* translators: %s: parameter name */
|
||||
sprintf( __( 'Invalid date format for %s. Expected Y-m-d.', 'peptide-news' ), $param ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /articles
|
||||
*
|
||||
* Returns active articles with server-side transient caching.
|
||||
* Cache is invalidated whenever new articles are stored.
|
||||
*/
|
||||
public function get_articles( $request ) {
|
||||
global $wpdb;
|
||||
/**
|
||||
* GET /articles
|
||||
*
|
||||
* Returns active articles with server-side transient caching.
|
||||
* Cache is invalidated whenever new articles are stored.
|
||||
*/
|
||||
public function get_articles( $request ) {
|
||||
global $wpdb;
|
||||
|
||||
$count = absint( min( $request->get_param( 'count' ), 100 ) );
|
||||
$page = absint( max( $request->get_param( 'page' ), 1 ) );
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$count = absint( min( $request->get_param( 'count' ), 100 ) );
|
||||
$page = absint( max( $request->get_param( 'page' ), 1 ) );
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
// Build cache key from page and count params.
|
||||
$cache_key = 'peptide_news_articles_' . $page . '_' . $count;
|
||||
$cached_response = get_transient( $cache_key );
|
||||
if ( false !== $cached_response ) {
|
||||
return rest_ensure_response( $cached_response );
|
||||
}
|
||||
// Build cache key from page and count params.
|
||||
$cache_key = 'peptide_news_articles_' . $page . '_' . $count;
|
||||
$cached_response = get_transient( $cache_key );
|
||||
if ( false !== $cached_response ) {
|
||||
return rest_ensure_response( $cached_response );
|
||||
}
|
||||
|
||||
$total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) );
|
||||
$total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) );
|
||||
|
||||
// Ensure page doesn't exceed max available pages.
|
||||
$max_page = max( 1, (int) ceil( $total / $count ) );
|
||||
$page = min( $page, $max_page );
|
||||
$offset = ( $page - 1 ) * $count;
|
||||
// Ensure page doesn't exceed max available pages.
|
||||
$max_page = max( 1, (int) ceil( $total / $count ) );
|
||||
$page = min( $page, $max_page );
|
||||
$offset = ( $page - 1 ) * $count;
|
||||
|
||||
$articles = $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT id, source, source_url, title, excerpt, ai_summary, author,
|
||||
thumbnail_url, thumbnail_local, published_at, categories, tags
|
||||
FROM {$table}
|
||||
WHERE is_active = 1
|
||||
ORDER BY published_at DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
$count,
|
||||
$offset
|
||||
) );
|
||||
$articles = $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT id, source, source_url, title, excerpt, ai_summary, author,
|
||||
thumbnail_url, thumbnail_local, published_at, categories, tags
|
||||
FROM {$table}
|
||||
WHERE is_active = 1
|
||||
ORDER BY published_at DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
$count,
|
||||
$offset
|
||||
) );
|
||||
|
||||
// Strip source name from titles, excerpts, and summaries.
|
||||
$nbsp = "\xC2\xA0"; // UTF-8 non-breaking space.
|
||||
foreach ( $articles as $article ) {
|
||||
$fields = array( 'title', 'excerpt', 'ai_summary' );
|
||||
foreach ( $fields as $field ) {
|
||||
if ( empty( $article->$field ) ) {
|
||||
continue;
|
||||
}
|
||||
// First pass: use strip_source_suffix for known patterns.
|
||||
$article->$field = $this->strip_source_suffix( $article->$field, $article->source, $article->source_url );
|
||||
// Strip source name from titles, excerpts, and summaries.
|
||||
$nbsp = "\xC2\xA0"; // UTF-8 non-breaking space.
|
||||
foreach ( $articles as $article ) {
|
||||
$fields = array( 'title', 'excerpt', 'ai_summary' );
|
||||
foreach ( $fields as $field ) {
|
||||
if ( empty( $article->$field ) ) {
|
||||
continue;
|
||||
}
|
||||
// First pass: use strip_source_suffix for known patterns.
|
||||
$article->$field = $this->strip_source_suffix( $article->$field, $article->source, $article->source_url );
|
||||
|
||||
// Second pass: catch any remaining nbsp-separated publisher names
|
||||
// (e.g., "...text\xC2\xA0\xC2\xA0Publisher Name" from Google News RSS).
|
||||
$text = $article->$field;
|
||||
$pos = strrpos( $text, $nbsp . $nbsp );
|
||||
if ( false === $pos ) {
|
||||
$pos = strrpos( $text, $nbsp );
|
||||
}
|
||||
if ( false !== $pos && $pos > 10 ) {
|
||||
$candidate = rtrim( substr( $text, 0, $pos ) );
|
||||
if ( strlen( $candidate ) > strlen( $text ) * 0.3 ) {
|
||||
$article->$field = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: catch any remaining nbsp-separated publisher names
|
||||
// (e.g., "...text\xC2\xA0\xC2\xA0Publisher Name" from Google News RSS).
|
||||
$text = $article->$field;
|
||||
$pos = strrpos( $text, $nbsp . $nbsp );
|
||||
if ( false === $pos ) {
|
||||
$pos = strrpos( $text, $nbsp );
|
||||
}
|
||||
if ( false !== $pos && $pos > 10 ) {
|
||||
$candidate = rtrim( substr( $text, 0, $pos ) );
|
||||
if ( strlen( $candidate ) > strlen( $text ) * 0.3 ) {
|
||||
$article->$field = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
$data = array(
|
||||
'articles' => $articles,
|
||||
'total' => (int) $total,
|
||||
'page' => $page,
|
||||
'per_page' => $count,
|
||||
'total_pages' => ceil( $total / $count ),
|
||||
);
|
||||
|
||||
$data = array(
|
||||
'articles' => $articles,
|
||||
'total' => (int) $total,
|
||||
'page' => $page,
|
||||
'per_page' => $count,
|
||||
'total_pages' => ceil( $total / $count ),
|
||||
);
|
||||
// Cache the response for 1 hour.
|
||||
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
|
||||
|
||||
// Cache the response for 1 hour.
|
||||
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
|
||||
$response = rest_ensure_response( $data );
|
||||
|
||||
$response = rest_ensure_response( $data );
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
/**
|
||||
* Strip the source/publisher name from the end of a string.
|
||||
*
|
||||
* Handles common separator patterns like " - Source", " | Source",
|
||||
* " — Source", and " – Source". Uses case-insensitive matching against
|
||||
* the source field. Also extracts publisher names from source URLs
|
||||
* and has a regex fallback for common RSS title patterns.
|
||||
*
|
||||
* @param string $text The text to clean.
|
||||
* @param string $source The source name to strip.
|
||||
* @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 = '' ) {
|
||||
if ( empty( $text ) ) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the source/publisher name from the end of a string.
|
||||
*
|
||||
* Handles common separator patterns like " - Source", " | Source",
|
||||
* " — Source", and " – Source". Uses case-insensitive matching against
|
||||
* the source field. Also extracts publisher names from source URLs
|
||||
* and has a regex fallback for common RSS title patterns.
|
||||
*
|
||||
* @param string $text The text to clean.
|
||||
* @param string $source The source name to strip.
|
||||
* @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 = '' ) {
|
||||
if ( empty( $text ) ) {
|
||||
return $text;
|
||||
}
|
||||
// Decode HTML entities first so becomes actual characters.
|
||||
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
|
||||
|
||||
// Decode HTML entities first so becomes actual characters.
|
||||
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
|
||||
// Common separators between title/excerpt and source name.
|
||||
// Includes regular spaces, non-breaking spaces (\xC2\xA0), and typographic dashes.
|
||||
$nbsp = "\xC2\xA0"; // UTF-8 non-breaking space
|
||||
$separators = array(
|
||||
' - ', ' | ', ' — ', ' – ', ' // ',
|
||||
$nbsp . $nbsp, // double non-breaking space (Google News RSS excerpts)
|
||||
' ', // double regular space
|
||||
$nbsp, // single non-breaking space
|
||||
);
|
||||
|
||||
// Common separators between title/excerpt and source name.
|
||||
// Includes regular spaces, non-breaking spaces (\xC2\xA0), and typographic dashes.
|
||||
$nbsp = "\xC2\xA0"; // UTF-8 non-breaking space
|
||||
$separators = array(
|
||||
' - ', ' | ', ' — ', ' – ', ' // ',
|
||||
$nbsp . $nbsp, // double non-breaking space (Google News RSS excerpts)
|
||||
' ', // double regular space
|
||||
$nbsp, // single non-breaking space
|
||||
);
|
||||
// Build list of source names to try matching against.
|
||||
$source_names = array();
|
||||
if ( ! empty( $source ) ) {
|
||||
$source_names[] = $source;
|
||||
}
|
||||
|
||||
// Build list of source names to try matching against.
|
||||
$source_names = array();
|
||||
if ( ! empty( $source ) ) {
|
||||
$source_names[] = $source;
|
||||
}
|
||||
// Get cached source names list to avoid database queries on every API call.
|
||||
$cached_sources = get_transient( 'peptide_news_source_names' );
|
||||
if ( false === $cached_sources ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" );
|
||||
if ( ! is_array( $cached_sources ) ) {
|
||||
$cached_sources = array();
|
||||
}
|
||||
set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS );
|
||||
}
|
||||
$source_names = array_merge( $source_names, $cached_sources );
|
||||
|
||||
// Get cached source names list to avoid database queries on every API call.
|
||||
$cached_sources = get_transient( 'peptide_news_source_names' );
|
||||
if ( false === $cached_sources ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" );
|
||||
if ( ! is_array( $cached_sources ) ) {
|
||||
$cached_sources = array();
|
||||
}
|
||||
set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS );
|
||||
}
|
||||
$source_names = array_merge( $source_names, $cached_sources );
|
||||
// Extract domain-based publisher name (e.g., "News-Medical" from "news-medical.net").
|
||||
if ( ! empty( $source_url ) ) {
|
||||
$host = wp_parse_url( $source_url, PHP_URL_HOST );
|
||||
if ( $host ) {
|
||||
$host = preg_replace( '/^www\./', '', $host );
|
||||
$parts = explode( '.', $host );
|
||||
if ( count( $parts ) >= 2 ) {
|
||||
$domain_name = $parts[ count( $parts ) - 2 ];
|
||||
$source_names[] = str_replace( '-', ' ', $domain_name );
|
||||
$source_names[] = $domain_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract domain-based publisher name (e.g., "News-Medical" from "news-medical.net").
|
||||
if ( ! empty( $source_url ) ) {
|
||||
$host = wp_parse_url( $source_url, PHP_URL_HOST );
|
||||
if ( $host ) {
|
||||
$host = preg_replace( '/^www\./', '', $host );
|
||||
$parts = explode( '.', $host );
|
||||
if ( count( $parts ) >= 2 ) {
|
||||
$domain_name = $parts[ count( $parts ) - 2 ];
|
||||
$source_names[] = str_replace( '-', ' ', $domain_name );
|
||||
$source_names[] = $domain_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try exact (case-insensitive) match against known source names.
|
||||
foreach ( $source_names as $name ) {
|
||||
foreach ( $separators as $sep ) {
|
||||
$suffix = $sep . $name;
|
||||
$suffix_len = strlen( $suffix );
|
||||
if ( strlen( $text ) > $suffix_len && strcasecmp( substr( $text, -$suffix_len ), $suffix ) === 0 ) {
|
||||
return rtrim( substr( $text, 0, -$suffix_len ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try exact (case-insensitive) match against known source names.
|
||||
foreach ( $source_names as $name ) {
|
||||
foreach ( $separators as $sep ) {
|
||||
$suffix = $sep . $name;
|
||||
$suffix_len = strlen( $suffix );
|
||||
if ( strlen( $text ) > $suffix_len && strcasecmp( substr( $text, -$suffix_len ), $suffix ) === 0 ) {
|
||||
return rtrim( substr( $text, 0, -$suffix_len ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback 1: strip trailing " - Short Publisher Name" patterns (1-5 words).
|
||||
// Only strips if the part after the separator looks like a publisher name
|
||||
// (starts with uppercase, no sentence-ending punctuation).
|
||||
$pattern = '/\s+[-\x{2013}\x{2014}|]\s+[A-Z][A-Za-z0-9\s.\'\-]{0,50}$/u';
|
||||
$candidate = preg_replace( $pattern, '', $text );
|
||||
if ( $candidate !== $text && strlen( $candidate ) > strlen( $text ) * 0.4 ) {
|
||||
return rtrim( $candidate );
|
||||
}
|
||||
|
||||
// Fallback 1: strip trailing " - Short Publisher Name" patterns (1-5 words).
|
||||
// Only strips if the part after the separator looks like a publisher name
|
||||
// (starts with uppercase, no sentence-ending punctuation).
|
||||
$pattern = '/\s+[-\x{2013}\x{2014}|]\s+[A-Z][A-Za-z0-9\s.\'\-]{0,50}$/u';
|
||||
$candidate = preg_replace( $pattern, '', $text );
|
||||
if ( $candidate !== $text && strlen( $candidate ) > strlen( $text ) * 0.4 ) {
|
||||
return rtrim( $candidate );
|
||||
}
|
||||
// Fallback 2: strip trailing nbsp-separated publisher names.
|
||||
// Google News RSS excerpts use double non-breaking space + publisher name
|
||||
// when the source field doesn't match (e.g., source = "news.google.com").
|
||||
// Uses mb_ereg for reliable multibyte matching.
|
||||
if ( preg_match( '/^(.{10,}?)\x{00A0}{1,2}([A-Z][^.!?\x{00A0}]{0,55})$/u', $text, $m ) ) {
|
||||
if ( mb_strlen( $m[1], 'UTF-8' ) > mb_strlen( $text, 'UTF-8' ) * 0.4 ) {
|
||||
return rtrim( $m[1] );
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 2: strip trailing nbsp-separated publisher names.
|
||||
// Google News RSS excerpts use double non-breaking space + publisher name
|
||||
// when the source field doesn't match (e.g., source = "news.google.com").
|
||||
// Uses mb_ereg for reliable multibyte matching.
|
||||
if ( preg_match( '/^(.{10,}?)\x{00A0}{1,2}([A-Z][^.!?\x{00A0}]{0,55})$/u', $text, $m ) ) {
|
||||
if ( mb_strlen( $m[1], 'UTF-8' ) > mb_strlen( $text, 'UTF-8' ) * 0.4 ) {
|
||||
return rtrim( $m[1] );
|
||||
}
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
/**
|
||||
* GET /analytics/top
|
||||
*/
|
||||
public function get_top_articles( $request ) {
|
||||
$data = Peptide_News_Analytics::get_top_articles(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' ),
|
||||
$request->get_param( 'limit' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/top
|
||||
*/
|
||||
public function get_top_articles( $request ) {
|
||||
$data = Peptide_News_Analytics::get_top_articles(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' ),
|
||||
$request->get_param( 'limit' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
/**
|
||||
* GET /analytics/trends
|
||||
*/
|
||||
public function get_trends( $request ) {
|
||||
$data = Peptide_News_Analytics::get_click_trends(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/trends
|
||||
*/
|
||||
public function get_trends( $request ) {
|
||||
$data = Peptide_News_Analytics::get_click_trends(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
/**
|
||||
* GET /analytics/topics
|
||||
*/
|
||||
public function get_topics( $request ) {
|
||||
$data = Peptide_News_Analytics::get_popular_topics(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' ),
|
||||
$request->get_param( 'limit' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/topics
|
||||
*/
|
||||
public function get_topics( $request ) {
|
||||
$data = Peptide_News_Analytics::get_popular_topics(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' ),
|
||||
$request->get_param( 'limit' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
/**
|
||||
* GET /analytics/devices
|
||||
*/
|
||||
public function get_devices( $request ) {
|
||||
$data = Peptide_News_Analytics::get_device_breakdown(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/devices
|
||||
*/
|
||||
public function get_devices( $request ) {
|
||||
$data = Peptide_News_Analytics::get_device_breakdown(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
/**
|
||||
* GET /analytics/sources
|
||||
*/
|
||||
public function get_sources( $request ) {
|
||||
$data = Peptide_News_Analytics::get_source_performance(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/sources
|
||||
*/
|
||||
public function get_sources( $request ) {
|
||||
$data = Peptide_News_Analytics::get_source_performance(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
return rest_ensure_response( $data );
|
||||
}
|
||||
/**
|
||||
* GET /analytics/export — returns CSV download.
|
||||
*/
|
||||
public function get_export( $request ) {
|
||||
$data = Peptide_News_Analytics::export_clicks_csv(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /analytics/export — returns CSV download.
|
||||
*/
|
||||
public function get_export( $request ) {
|
||||
$data = Peptide_News_Analytics::export_clicks_csv(
|
||||
$request->get_param( 'start_date' ),
|
||||
$request->get_param( 'end_date' )
|
||||
);
|
||||
if ( empty( $data ) ) {
|
||||
return rest_ensure_response( array( 'message' => 'No data for the selected period.' ) );
|
||||
}
|
||||
|
||||
if ( empty( $data ) ) {
|
||||
return rest_ensure_response( array( 'message' => 'No data for the selected period.' ) );
|
||||
}
|
||||
// Return as JSON (client can convert to CSV).
|
||||
return rest_ensure_response( array(
|
||||
'headers' => array_keys( $data[0] ),
|
||||
'rows' => $data,
|
||||
'count' => count( $data ),
|
||||
) );
|
||||
}
|
||||
|
||||
// Return as JSON (client can convert to CSV).
|
||||
return rest_ensure_response( array(
|
||||
'headers' => array_keys( $data[0] ),
|
||||
'rows' => $data,
|
||||
'count' => count( $data ),
|
||||
) );
|
||||
}
|
||||
/**
|
||||
* AJAX handler: delete one or more articles by ID.
|
||||
*
|
||||
* Expects POST with 'ids' (comma-separated article IDs) and 'nonce'.
|
||||
* Cascading foreign keys on the clicks and daily_stats tables handle
|
||||
* related analytics cleanup automatically.
|
||||
*
|
||||
* @since 2.1.0
|
||||
*/
|
||||
public static function ajax_delete_articles() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
/**
|
||||
* AJAX handler: delete one or more articles by ID.
|
||||
*
|
||||
* Expects POST with 'ids' (comma-separated article IDs) and 'nonce'.
|
||||
* Cascading foreign keys on the clicks and daily_stats tables handle
|
||||
* related analytics cleanup automatically.
|
||||
*
|
||||
* @since 2.1.0
|
||||
*/
|
||||
public static function ajax_delete_articles() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized.', 403 );
|
||||
}
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized.', 403 );
|
||||
}
|
||||
$raw_ids = isset( $_POST['ids'] ) ? sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : '';
|
||||
if ( empty( $raw_ids ) ) {
|
||||
wp_send_json_error( 'No article IDs provided.' );
|
||||
}
|
||||
|
||||
$raw_ids = isset( $_POST['ids'] ) ? sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : '';
|
||||
if ( empty( $raw_ids ) ) {
|
||||
wp_send_json_error( 'No article IDs provided.' );
|
||||
}
|
||||
$ids = array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) );
|
||||
if ( empty( $ids ) ) {
|
||||
wp_send_json_error( 'Invalid article IDs.' );
|
||||
}
|
||||
|
||||
$ids = array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) );
|
||||
if ( empty( $ids ) ) {
|
||||
wp_send_json_error( 'Invalid article IDs.' );
|
||||
}
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$deleted = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"DELETE FROM {$table} WHERE id IN ({$placeholders})",
|
||||
$ids
|
||||
)
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$deleted = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"DELETE FROM {$table} WHERE id IN ({$placeholders})",
|
||||
$ids
|
||||
)
|
||||
);
|
||||
if ( false === $deleted ) {
|
||||
wp_send_json_error( 'Database error.' );
|
||||
}
|
||||
|
||||
if ( false === $deleted ) {
|
||||
wp_send_json_error( 'Database error.' );
|
||||
}
|
||||
// Clear article cache so the frontend reflects changes.
|
||||
delete_transient( 'peptide_news_articles_1_10' );
|
||||
|
||||
// Clear article cache so the frontend reflects changes.
|
||||
delete_transient( 'peptide_news_articles_1_10' );
|
||||
Peptide_News_Logger::info( 'Deleted ' . $deleted . ' article(s): IDs ' . implode( ', ', $ids ), 'admin' );
|
||||
|
||||
Peptide_News_Logger::info( 'Deleted ' . $deleted . ' article(s): IDs ' . implode( ', ', $ids ), 'admin' );
|
||||
|
||||
wp_send_json_success( array(
|
||||
'deleted' => $deleted,
|
||||
'ids' => $ids,
|
||||
) );
|
||||
}
|
||||
wp_send_json_success( array(
|
||||
'deleted' => $deleted,
|
||||
'ids' => $ids,
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,111 +8,111 @@
|
||||
*/
|
||||
class Peptide_News {
|
||||
|
||||
/** @var Peptide_News_Loader */
|
||||
protected $loader;
|
||||
/** @var Peptide_News_Loader */
|
||||
protected $loader;
|
||||
|
||||
/** @var string */
|
||||
protected $plugin_name;
|
||||
/** @var string */
|
||||
protected $plugin_name;
|
||||
|
||||
/** @var string */
|
||||
protected $version;
|
||||
/** @var string */
|
||||
protected $version;
|
||||
|
||||
public function __construct() {
|
||||
$this->version = PEPTIDE_NEWS_VERSION;
|
||||
$this->plugin_name = 'peptide-news';
|
||||
public function __construct() {
|
||||
$this->version = PEPTIDE_NEWS_VERSION;
|
||||
$this->plugin_name = 'peptide-news';
|
||||
|
||||
$this->load_dependencies();
|
||||
$this->define_admin_hooks();
|
||||
$this->define_public_hooks();
|
||||
$this->define_cron_hooks();
|
||||
$this->define_rest_hooks();
|
||||
}
|
||||
$this->load_dependencies();
|
||||
$this->define_admin_hooks();
|
||||
$this->define_public_hooks();
|
||||
$this->define_cron_hooks();
|
||||
$this->define_rest_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all required files.
|
||||
*/
|
||||
private function load_dependencies() {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php';
|
||||
/**
|
||||
* Load all required files.
|
||||
*/
|
||||
private function load_dependencies() {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php';
|
||||
|
||||
$this->loader = new Peptide_News_Loader();
|
||||
}
|
||||
$this->loader = new Peptide_News_Loader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register admin-side hooks.
|
||||
*/
|
||||
private function define_admin_hooks() {
|
||||
$admin = new Peptide_News_Admin( $this->plugin_name, $this->version );
|
||||
/**
|
||||
* Register admin-side hooks.
|
||||
*/
|
||||
private function define_admin_hooks() {
|
||||
$admin = new Peptide_News_Admin( $this->plugin_name, $this->version );
|
||||
|
||||
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' );
|
||||
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' );
|
||||
$this->loader->add_action( 'admin_menu', $admin, 'add_admin_menu' );
|
||||
$this->loader->add_action( 'admin_init', $admin, 'register_settings' );
|
||||
}
|
||||
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' );
|
||||
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' );
|
||||
$this->loader->add_action( 'admin_menu', $admin, 'add_admin_menu' );
|
||||
$this->loader->add_action( 'admin_init', $admin, 'register_settings' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register public-facing hooks.
|
||||
*/
|
||||
private function define_public_hooks() {
|
||||
$public = new Peptide_News_Public( $this->plugin_name, $this->version );
|
||||
/**
|
||||
* Register public-facing hooks.
|
||||
*/
|
||||
private function define_public_hooks() {
|
||||
$public = new Peptide_News_Public( $this->plugin_name, $this->version );
|
||||
|
||||
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' );
|
||||
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' );
|
||||
$this->loader->add_action( 'init', $public, 'register_shortcode' );
|
||||
$this->loader->add_action( 'widgets_init', $public, 'register_widget' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_track_click', $public, 'handle_click_tracking' );
|
||||
$this->loader->add_action( 'wp_ajax_nopriv_peptide_news_track_click', $public, 'handle_click_tracking' );
|
||||
}
|
||||
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' );
|
||||
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' );
|
||||
$this->loader->add_action( 'init', $public, 'register_shortcode' );
|
||||
$this->loader->add_action( 'widgets_init', $public, 'register_widget' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_track_click', $public, 'handle_click_tracking' );
|
||||
$this->loader->add_action( 'wp_ajax_nopriv_peptide_news_track_click', $public, 'handle_click_tracking' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WP-Cron hooks for scheduled fetching.
|
||||
*/
|
||||
private function define_cron_hooks() {
|
||||
$fetcher = new Peptide_News_Fetcher();
|
||||
/**
|
||||
* Register WP-Cron hooks for scheduled fetching.
|
||||
*/
|
||||
private function define_cron_hooks() {
|
||||
$fetcher = new Peptide_News_Fetcher();
|
||||
|
||||
$this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' );
|
||||
$this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' );
|
||||
$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( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' );
|
||||
$this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' );
|
||||
$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' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API hooks.
|
||||
*/
|
||||
private function define_rest_hooks() {
|
||||
$rest = new Peptide_News_Rest_API();
|
||||
/**
|
||||
* Register REST API hooks.
|
||||
*/
|
||||
private function define_rest_hooks() {
|
||||
$rest = new Peptide_News_Rest_API();
|
||||
|
||||
$this->loader->add_action( 'rest_api_init', $rest, 'register_routes' );
|
||||
}
|
||||
$this->loader->add_action( 'rest_api_init', $rest, 'register_routes' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the loader to execute all registered hooks.
|
||||
*/
|
||||
public function run() {
|
||||
$this->loader->run();
|
||||
}
|
||||
/**
|
||||
* Run the loader to execute all registered hooks.
|
||||
*/
|
||||
public function run() {
|
||||
$this->loader->run();
|
||||
}
|
||||
|
||||
public function get_plugin_name() {
|
||||
return $this->plugin_name;
|
||||
}
|
||||
public function get_plugin_name() {
|
||||
return $this->plugin_name;
|
||||
}
|
||||
|
||||
public function get_version() {
|
||||
return $this->version;
|
||||
}
|
||||
public function get_version() {
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function get_loader() {
|
||||
return $this->loader;
|
||||
}
|
||||
public function get_loader() {
|
||||
return $this->loader;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
// Abort if called directly.
|
||||
if ( ! defined( 'WPINC' ) ) {
|
||||
die;
|
||||
die;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,16 +31,16 @@ define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
* Runs during plugin activation.
|
||||
*/
|
||||
function peptide_news_activate() {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
Peptide_News_Activator::activate();
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
Peptide_News_Activator::activate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs during plugin deactivation.
|
||||
*/
|
||||
function peptide_news_deactivate() {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
||||
Peptide_News_Deactivator::deactivate();
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
||||
Peptide_News_Deactivator::deactivate();
|
||||
}
|
||||
|
||||
register_activation_hook( __FILE__, 'peptide_news_activate' );
|
||||
@@ -57,12 +57,12 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news.php';
|
||||
* without re-activating the plugin.
|
||||
*/
|
||||
function peptide_news_check_db_upgrade() {
|
||||
$installed_version = get_option( 'peptide_news_db_version', '1.0.0' );
|
||||
if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
|
||||
Peptide_News_Activator::activate();
|
||||
}
|
||||
$installed_version = get_option( 'peptide_news_db_version', '1.0.0' );
|
||||
if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
|
||||
Peptide_News_Activator::activate();
|
||||
}
|
||||
}
|
||||
add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' );
|
||||
|
||||
@@ -70,8 +70,8 @@ add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' );
|
||||
* Begins execution of the plugin.
|
||||
*/
|
||||
function peptide_news_run() {
|
||||
$plugin = new Peptide_News();
|
||||
$plugin->run();
|
||||
$plugin = new Peptide_News();
|
||||
$plugin->run();
|
||||
}
|
||||
|
||||
peptide_news_run();
|
||||
|
||||
@@ -10,161 +10,161 @@
|
||||
*/
|
||||
class Peptide_News_Public {
|
||||
|
||||
/** @var string */
|
||||
private $plugin_name;
|
||||
/** @var string */
|
||||
private $plugin_name;
|
||||
|
||||
/** @var string */
|
||||
private $version;
|
||||
/** @var string */
|
||||
private $version;
|
||||
|
||||
public function __construct( $plugin_name, $version ) {
|
||||
$this->plugin_name = $plugin_name;
|
||||
$this->version = $version;
|
||||
}
|
||||
public function __construct( $plugin_name, $version ) {
|
||||
$this->plugin_name = $plugin_name;
|
||||
$this->version = $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register front-end CSS (enqueued on demand by the shortcode/widget).
|
||||
*/
|
||||
public function enqueue_styles() {
|
||||
wp_register_style(
|
||||
$this->plugin_name,
|
||||
PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css',
|
||||
array(),
|
||||
$this->version,
|
||||
'all'
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Register front-end CSS (enqueued on demand by the shortcode/widget).
|
||||
*/
|
||||
public function enqueue_styles() {
|
||||
wp_register_style(
|
||||
$this->plugin_name,
|
||||
PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css',
|
||||
array(),
|
||||
$this->version,
|
||||
'all'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register front-end JS (enqueued on demand by the shortcode/widget).
|
||||
*
|
||||
* Loads React (from WordPress core), the feed component, and
|
||||
* passes configuration via wp_localize_script.
|
||||
*/
|
||||
public function enqueue_scripts() {
|
||||
// React feed component (depends on WP-bundled React).
|
||||
wp_register_script(
|
||||
$this->plugin_name . '-feed',
|
||||
PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js',
|
||||
array( 'react', 'react-dom' ),
|
||||
$this->version,
|
||||
true
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Register front-end JS (enqueued on demand by the shortcode/widget).
|
||||
*
|
||||
* Loads React (from WordPress core), the feed component, and
|
||||
* passes configuration via wp_localize_script.
|
||||
*/
|
||||
public function enqueue_scripts() {
|
||||
// React feed component (depends on WP-bundled React).
|
||||
wp_register_script(
|
||||
$this->plugin_name . '-feed',
|
||||
PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js',
|
||||
array( 'react', 'react-dom' ),
|
||||
$this->version,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the shortcode.
|
||||
*/
|
||||
public function register_shortcode() {
|
||||
add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) );
|
||||
}
|
||||
/**
|
||||
* Register the shortcode.
|
||||
*/
|
||||
public function register_shortcode() {
|
||||
add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the widget.
|
||||
*/
|
||||
public function register_widget() {
|
||||
register_widget( 'Peptide_News_Widget' );
|
||||
}
|
||||
/**
|
||||
* Register the widget.
|
||||
*/
|
||||
public function register_widget() {
|
||||
register_widget( 'Peptide_News_Widget' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the [peptide_news] shortcode.
|
||||
*
|
||||
* Outputs a mount-point div for the React feed component and
|
||||
* enqueues all required assets. Configuration is passed to JS
|
||||
* via a localized script object.
|
||||
*
|
||||
* @param array $atts Shortcode attributes.
|
||||
* @return string HTML output.
|
||||
*/
|
||||
public function render_shortcode( $atts ) {
|
||||
$atts = shortcode_atts( array(
|
||||
'count' => get_option( 'peptide_news_articles_count', 10 ),
|
||||
), $atts, 'peptide_news' );
|
||||
/**
|
||||
* Render the [peptide_news] shortcode.
|
||||
*
|
||||
* Outputs a mount-point div for the React feed component and
|
||||
* enqueues all required assets. Configuration is passed to JS
|
||||
* via a localized script object.
|
||||
*
|
||||
* @param array $atts Shortcode attributes.
|
||||
* @return string HTML output.
|
||||
*/
|
||||
public function render_shortcode( $atts ) {
|
||||
$atts = shortcode_atts( array(
|
||||
'count' => get_option( 'peptide_news_articles_count', 10 ),
|
||||
), $atts, 'peptide_news' );
|
||||
|
||||
$count = absint( $atts['count'] );
|
||||
$count = absint( $atts['count'] );
|
||||
|
||||
// Enqueue assets only when the shortcode is actually rendered.
|
||||
wp_enqueue_style( $this->plugin_name );
|
||||
wp_enqueue_script( $this->plugin_name . '-feed' );
|
||||
// Enqueue assets only when the shortcode is actually rendered.
|
||||
wp_enqueue_style( $this->plugin_name );
|
||||
wp_enqueue_script( $this->plugin_name . '-feed' );
|
||||
|
||||
// Pass configuration to the React component.
|
||||
wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array(
|
||||
'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ),
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'peptide_news_track' ),
|
||||
'sessionId' => $this->get_or_create_session_id(),
|
||||
'count' => $count,
|
||||
) );
|
||||
// Pass configuration to the React component.
|
||||
wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array(
|
||||
'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ),
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'peptide_news_track' ),
|
||||
'sessionId' => $this->get_or_create_session_id(),
|
||||
'count' => $count,
|
||||
) );
|
||||
|
||||
// React mount point — the component renders into this div.
|
||||
return '<div data-peptide-news-feed></div>';
|
||||
}
|
||||
// React mount point — the component renders into this div.
|
||||
return '<div data-peptide-news-feed></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX click tracking with basic rate limiting.
|
||||
*/
|
||||
public function handle_click_tracking() {
|
||||
check_ajax_referer( 'peptide_news_track', 'nonce' );
|
||||
/**
|
||||
* Handle AJAX click tracking with basic rate limiting.
|
||||
*/
|
||||
public function handle_click_tracking() {
|
||||
check_ajax_referer( 'peptide_news_track', 'nonce' );
|
||||
|
||||
$article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0;
|
||||
$article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0;
|
||||
|
||||
if ( ! $article_id ) {
|
||||
wp_send_json_error( 'Invalid article ID.' );
|
||||
}
|
||||
if ( ! $article_id ) {
|
||||
wp_send_json_error( 'Invalid article ID.' );
|
||||
}
|
||||
|
||||
// Rate limit: max 1 click per article per session per 5 seconds.
|
||||
$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
|
||||
$throttle_key = 'pn_click_' . md5( $session_id . '_' . $article_id );
|
||||
// Rate limit: max 1 click per article per session per 5 seconds.
|
||||
$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
|
||||
$throttle_key = 'pn_click_' . md5( $session_id . '_' . $article_id );
|
||||
|
||||
if ( get_transient( $throttle_key ) ) {
|
||||
wp_send_json_success( array( 'throttled' => true ) );
|
||||
}
|
||||
set_transient( $throttle_key, 1, 5 );
|
||||
if ( get_transient( $throttle_key ) ) {
|
||||
wp_send_json_success( array( 'throttled' => true ) );
|
||||
}
|
||||
set_transient( $throttle_key, 1, 5 );
|
||||
|
||||
$meta = array(
|
||||
'ip' => '',
|
||||
'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
|
||||
'referrer' => isset( $_POST['referrer'] ) ? esc_url_raw( wp_unslash( $_POST['referrer'] ) ) : '',
|
||||
'page_url' => isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : '',
|
||||
'session_id' => $session_id,
|
||||
);
|
||||
$meta = array(
|
||||
'ip' => '',
|
||||
'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
|
||||
'referrer' => isset( $_POST['referrer'] ) ? esc_url_raw( wp_unslash( $_POST['referrer'] ) ) : '',
|
||||
'page_url' => isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : '',
|
||||
'session_id' => $session_id,
|
||||
);
|
||||
|
||||
$result = Peptide_News_Analytics::record_click( $article_id, $meta );
|
||||
$result = Peptide_News_Analytics::record_click( $article_id, $meta );
|
||||
|
||||
if ( $result ) {
|
||||
wp_send_json_success();
|
||||
} else {
|
||||
wp_send_json_error( 'Failed to record click.' );
|
||||
}
|
||||
}
|
||||
if ( $result ) {
|
||||
wp_send_json_success();
|
||||
} else {
|
||||
wp_send_json_error( 'Failed to record click.' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or retrieve a session ID for visitor tracking.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_or_create_session_id() {
|
||||
if ( isset( $_COOKIE['pn_session_id'] ) ) {
|
||||
return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) );
|
||||
}
|
||||
/**
|
||||
* Generate or retrieve a session ID for visitor tracking.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_or_create_session_id() {
|
||||
if ( isset( $_COOKIE['pn_session_id'] ) ) {
|
||||
return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) );
|
||||
}
|
||||
|
||||
$session_id = wp_generate_uuid4();
|
||||
$session_id = wp_generate_uuid4();
|
||||
|
||||
if ( ! headers_sent() ) {
|
||||
setcookie(
|
||||
'pn_session_id',
|
||||
$session_id,
|
||||
array(
|
||||
'expires' => time() + 1800,
|
||||
'path' => '/',
|
||||
'secure' => is_ssl(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
)
|
||||
);
|
||||
}
|
||||
if ( ! headers_sent() ) {
|
||||
setcookie(
|
||||
'pn_session_id',
|
||||
$session_id,
|
||||
array(
|
||||
'expires' => time() + 1800,
|
||||
'path' => '/',
|
||||
'secure' => is_ssl(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $session_id;
|
||||
}
|
||||
return $session_id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,63 +174,63 @@ class Peptide_News_Public {
|
||||
*/
|
||||
class Peptide_News_Widget extends WP_Widget {
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct(
|
||||
'peptide_news_widget',
|
||||
__( 'Peptide News', 'peptide-news' ),
|
||||
array(
|
||||
'description' => __( 'Displays the latest peptide research news.', 'peptide-news' ),
|
||||
'classname' => 'peptide-news-widget',
|
||||
)
|
||||
);
|
||||
}
|
||||
public function __construct() {
|
||||
parent::__construct(
|
||||
'peptide_news_widget',
|
||||
__( 'Peptide News', 'peptide-news' ),
|
||||
array(
|
||||
'description' => __( 'Displays the latest peptide research news.', 'peptide-news' ),
|
||||
'classname' => 'peptide-news-widget',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function widget( $args, $instance ) {
|
||||
$title = apply_filters( 'widget_title', $instance['title'] ?? __( 'Peptide News', 'peptide-news' ) );
|
||||
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
|
||||
public function widget( $args, $instance ) {
|
||||
$title = apply_filters( 'widget_title', $instance['title'] ?? __( 'Peptide News', 'peptide-news' ) );
|
||||
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
|
||||
|
||||
echo $args['before_widget'];
|
||||
echo $args['before_widget'];
|
||||
|
||||
if ( $title ) {
|
||||
echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
|
||||
}
|
||||
if ( $title ) {
|
||||
echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
|
||||
}
|
||||
|
||||
$public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION );
|
||||
echo $public->render_shortcode( array( 'count' => $count ) );
|
||||
$public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION );
|
||||
echo $public->render_shortcode( array( 'count' => $count ) );
|
||||
|
||||
echo $args['after_widget'];
|
||||
}
|
||||
echo $args['after_widget'];
|
||||
}
|
||||
|
||||
public function form( $instance ) {
|
||||
if ( ! is_array( $instance ) ) {
|
||||
$instance = array();
|
||||
}
|
||||
$title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' );
|
||||
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
|
||||
?>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
|
||||
<?php esc_html_e( 'Title:', 'peptide-news' ); ?>
|
||||
</label>
|
||||
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
|
||||
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
|
||||
type="text" value="<?php echo esc_attr( $title ); ?>" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>">
|
||||
<?php esc_html_e( 'Number of articles:', 'peptide-news' ); ?>
|
||||
</label>
|
||||
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"
|
||||
name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>"
|
||||
type="number" min="1" max="50" value="<?php echo esc_attr( $count ); ?>" />
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
public function form( $instance ) {
|
||||
if ( ! is_array( $instance ) ) {
|
||||
$instance = array();
|
||||
}
|
||||
$title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' );
|
||||
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
|
||||
?>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
|
||||
<?php esc_html_e( 'Title:', 'peptide-news' ); ?>
|
||||
</label>
|
||||
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
|
||||
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
|
||||
type="text" value="<?php echo esc_attr( $title ); ?>" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>">
|
||||
<?php esc_html_e( 'Number of articles:', 'peptide-news' ); ?>
|
||||
</label>
|
||||
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"
|
||||
name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>"
|
||||
type="number" min="1" max="50" value="<?php echo esc_attr( $count ); ?>" />
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function update( $new_instance, $old_instance ) {
|
||||
$instance = array();
|
||||
$instance['title'] = sanitize_text_field( $new_instance['title'] ?? '' );
|
||||
$instance['count'] = absint( $new_instance['count'] ?? 5 );
|
||||
return $instance;
|
||||
}
|
||||
public function update( $new_instance, $old_instance ) {
|
||||
$instance = array();
|
||||
$instance['title'] = sanitize_text_field( $new_instance['title'] ?? '' );
|
||||
$instance['count'] = absint( $new_instance['count'] ?? 5 );
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user