Deploy 19fea30 from main
This commit is contained in:
289
admin/class-pn-admin-settings.php
Normal file
289
admin/class-pn-admin-settings.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Settings registration and sanitization.
|
||||
*
|
||||
* Registers all WordPress settings (7 sections, 25+ fields) and provides
|
||||
* sanitization callbacks. Field rendering is delegated to
|
||||
* Peptide_News_Admin_Field_Renderers for modularity.
|
||||
*
|
||||
* @since 2.5.0
|
||||
* @see Peptide_News_Admin_Field_Renderers — For field rendering
|
||||
* @see Peptide_News_Encryption — For API key encryption
|
||||
* @see Peptide_News_Cost_Tracker — For budget status in section header
|
||||
*/
|
||||
class Peptide_News_Admin_Settings {
|
||||
|
||||
/**
|
||||
* Register all WordPress settings and sections.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_settings(): void {
|
||||
|
||||
// --- General section ---
|
||||
add_settings_section(
|
||||
'peptide_news_general',
|
||||
__( 'General Settings', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'fetch_interval', __( 'Fetch Interval', 'peptide-news' ), 'render_fetch_interval_field', 'peptide_news_general' );
|
||||
$this->add_setting( 'articles_count', __( 'Articles to Display', 'peptide-news' ), 'render_articles_count_field', 'peptide_news_general' );
|
||||
$this->add_setting( 'search_keywords', __( 'Search Keywords', 'peptide-news' ), 'render_search_keywords_field', 'peptide_news_general' );
|
||||
|
||||
// --- RSS section ---
|
||||
add_settings_section(
|
||||
'peptide_news_rss',
|
||||
__( 'RSS Feed Sources', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'rss_enabled', __( 'Enable RSS Feeds', 'peptide-news' ), 'render_rss_enabled_field', 'peptide_news_rss' );
|
||||
$this->add_setting( 'rss_feeds', __( 'RSS Feed URLs', 'peptide-news' ), 'render_rss_feeds_field', 'peptide_news_rss' );
|
||||
|
||||
// --- NewsAPI section ---
|
||||
add_settings_section(
|
||||
'peptide_news_newsapi',
|
||||
__( 'NewsAPI.org', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'newsapi_enabled', __( 'Enable NewsAPI', 'peptide-news' ), 'render_newsapi_enabled_field', 'peptide_news_newsapi' );
|
||||
$this->add_setting( 'newsapi_key', __( 'API Key', 'peptide-news' ), 'render_newsapi_key_field', 'peptide_news_newsapi' );
|
||||
|
||||
// --- Analytics section ---
|
||||
add_settings_section(
|
||||
'peptide_news_analytics_section',
|
||||
__( 'Analytics', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'article_retention', __( 'Article Retention (days)', 'peptide-news' ), 'render_article_retention_field', 'peptide_news_analytics_section' );
|
||||
$this->add_setting( 'analytics_retention', __( 'Analytics Data Retention (days)', 'peptide-news' ), 'render_retention_field', 'peptide_news_analytics_section' );
|
||||
$this->add_setting( 'anonymize_ip', __( 'Anonymize IPs', 'peptide-news' ), 'render_anonymize_ip_field', 'peptide_news_analytics_section' );
|
||||
|
||||
// --- Content Filter section ---
|
||||
add_settings_section(
|
||||
'peptide_news_filter_section',
|
||||
__( 'Content Filter', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '</p>';
|
||||
$last_run = get_option( 'peptide_news_filter_last_run' );
|
||||
if ( $last_run && is_array( $last_run ) ) {
|
||||
printf(
|
||||
'<p class="description"><strong>%s:</strong> %s | %s: %d | %s: %d | %s: %d | %s: %d</p>',
|
||||
esc_html__( 'Last filter run', 'peptide-news' ),
|
||||
esc_html( $last_run['time'] ),
|
||||
esc_html__( 'Evaluated', 'peptide-news' ),
|
||||
absint( $last_run['total'] ),
|
||||
esc_html__( 'Removed', 'peptide-news' ),
|
||||
absint( $last_run['removed'] ),
|
||||
esc_html__( 'LLM checked', 'peptide-news' ),
|
||||
absint( $last_run['llm_checked'] ),
|
||||
esc_html__( 'Passed', 'peptide-news' ),
|
||||
absint( $last_run['passed'] )
|
||||
);
|
||||
}
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'filter_enabled', __( 'Enable Content Filter', 'peptide-news' ), 'render_filter_enabled_field', 'peptide_news_filter_section' );
|
||||
$this->add_setting( 'filter_sensitivity', __( 'Filter Sensitivity', 'peptide-news' ), 'render_filter_sensitivity_field', 'peptide_news_filter_section' );
|
||||
$this->add_setting( 'filter_llm_enabled', __( 'LLM Classification', 'peptide-news' ), 'render_filter_llm_enabled_field', 'peptide_news_filter_section' );
|
||||
$this->add_setting( 'filter_llm_model', __( 'Filter LLM Model', 'peptide-news' ), 'render_filter_llm_model_field', 'peptide_news_filter_section' );
|
||||
$this->add_setting( 'filter_title_keywords', __( 'Title Keywords', 'peptide-news' ), 'render_filter_title_keywords_field', 'peptide_news_filter_section' );
|
||||
$this->add_setting( 'filter_body_keywords', __( 'Body Keywords', 'peptide-news' ), 'render_filter_body_keywords_field', 'peptide_news_filter_section' );
|
||||
$this->add_setting( 'filter_blocked_domains', __( 'Blocked Domains', 'peptide-news' ), 'render_filter_blocked_domains_field', 'peptide_news_filter_section' );
|
||||
|
||||
// --- AI / LLM section ---
|
||||
add_settings_section(
|
||||
'peptide_news_llm_section',
|
||||
__( 'AI Analysis (OpenRouter)', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'llm_enabled', __( 'Enable AI Analysis', 'peptide-news' ), 'render_llm_enabled_field', 'peptide_news_llm_section' );
|
||||
$this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' );
|
||||
$this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_model_field', 'peptide_news_llm_section' );
|
||||
$this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' );
|
||||
$this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' );
|
||||
|
||||
// --- Cost Tracking / Budget section ---
|
||||
add_settings_section(
|
||||
'peptide_news_cost_section',
|
||||
__( 'Cost Tracking & Budget', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Monitor and control LLM API spending. Set a monthly budget to prevent unexpected charges.', 'peptide-news' ) . '</p>';
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) {
|
||||
$month_spend = Peptide_News_Cost_Tracker::get_current_month_spend();
|
||||
$budget = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
printf(
|
||||
'<p class="description"><strong>%s:</strong> $%.4f',
|
||||
esc_html__( 'This month\'s spend', 'peptide-news' ),
|
||||
$month_spend
|
||||
);
|
||||
if ( $budget > 0 ) {
|
||||
printf(
|
||||
' / $%.2f (%.1f%%)',
|
||||
$budget,
|
||||
( $month_spend / $budget ) * 100
|
||||
);
|
||||
}
|
||||
echo '</p>';
|
||||
}
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'monthly_budget', __( 'Monthly Budget (USD)', 'peptide-news' ), 'render_monthly_budget_field', 'peptide_news_cost_section' );
|
||||
$this->add_setting( 'budget_mode', __( 'Budget Enforcement', 'peptide-news' ), 'render_budget_mode_field', 'peptide_news_cost_section' );
|
||||
$this->add_setting( 'cost_retention', __( 'Cost Log Retention (days)', 'peptide-news' ), 'render_cost_retention_field', 'peptide_news_cost_section' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: register a single setting with a field callback.
|
||||
*
|
||||
* @param string $key Setting key (without prefix).
|
||||
* @param string $label Field label.
|
||||
* @param string $callback Render method name in Peptide_News_Admin_Field_Renderers.
|
||||
* @param string $section Settings section ID.
|
||||
* @return void
|
||||
*/
|
||||
private function add_setting( string $key, string $label, string $callback, string $section ): void {
|
||||
$option_name = "peptide_news_{$key}";
|
||||
|
||||
$sanitize_cb = $this->get_sanitize_callback( $key );
|
||||
|
||||
register_setting( 'peptide_news_settings_group', $option_name, array(
|
||||
'sanitize_callback' => $sanitize_cb,
|
||||
) );
|
||||
|
||||
add_settings_field(
|
||||
$option_name,
|
||||
$label,
|
||||
array( 'Peptide_News_Admin_Field_Renderers', $callback ),
|
||||
'peptide-news-settings',
|
||||
$section
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the appropriate sanitize callback for a given setting key.
|
||||
*
|
||||
* @param string $key Setting key (without prefix).
|
||||
* @return callable
|
||||
*/
|
||||
private function get_sanitize_callback( string $key ) {
|
||||
$callbacks = array(
|
||||
'fetch_interval' => 'sanitize_text_field',
|
||||
'articles_count' => 'absint',
|
||||
'search_keywords' => 'sanitize_text_field',
|
||||
'rss_enabled' => 'absint',
|
||||
'rss_feeds' => 'sanitize_textarea_field',
|
||||
'newsapi_enabled' => 'absint',
|
||||
'newsapi_key' => function ( $value ) {
|
||||
return $this->sanitize_api_key( $value, 'peptide_news_newsapi_key' );
|
||||
},
|
||||
'article_retention' => 'absint',
|
||||
'analytics_retention' => 'absint',
|
||||
'anonymize_ip' => 'absint',
|
||||
'filter_enabled' => 'absint',
|
||||
'filter_sensitivity' => 'sanitize_text_field',
|
||||
'filter_llm_enabled' => 'absint',
|
||||
'filter_llm_model' => array( $this, 'sanitize_model_id' ),
|
||||
'filter_title_keywords' => 'sanitize_textarea_field',
|
||||
'filter_body_keywords' => 'sanitize_textarea_field',
|
||||
'filter_blocked_domains' => 'sanitize_textarea_field',
|
||||
'llm_enabled' => 'absint',
|
||||
'openrouter_api_key' => function ( $value ) {
|
||||
return $this->sanitize_api_key( $value, 'peptide_news_openrouter_api_key' );
|
||||
},
|
||||
'llm_keywords_model' => array( $this, 'sanitize_model_id' ),
|
||||
'llm_summary_model' => array( $this, 'sanitize_model_id' ),
|
||||
'llm_max_articles' => 'absint',
|
||||
'monthly_budget' => function ( $value ) {
|
||||
return max( 0.0, (float) $value );
|
||||
},
|
||||
'budget_mode' => function ( $value ) {
|
||||
$valid = array( 'disabled', 'hard_stop', 'warn_only' );
|
||||
return in_array( $value, $valid, true ) ? $value : 'disabled';
|
||||
},
|
||||
'cost_retention' => function ( $value ) {
|
||||
return max( 30, absint( $value ) );
|
||||
},
|
||||
);
|
||||
|
||||
return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate an OpenRouter model ID.
|
||||
*
|
||||
* @param string $value Raw input.
|
||||
* @return string Sanitized model ID or default.
|
||||
*/
|
||||
public function sanitize_model_id( string $value ): string {
|
||||
$value = sanitize_text_field( $value );
|
||||
if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) {
|
||||
add_settings_error(
|
||||
'peptide_news_settings_group',
|
||||
'invalid_model',
|
||||
sprintf(
|
||||
/* translators: %s: model ID */
|
||||
__( 'Invalid model ID format: %s. Expected format: provider/model-name', 'peptide-news' ),
|
||||
esc_html( $value )
|
||||
),
|
||||
'error'
|
||||
);
|
||||
return 'google/gemini-2.0-flash-001';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and encrypt API keys before storage.
|
||||
*
|
||||
* If a masked key is submitted (••••••••XXXX format), the existing
|
||||
* encrypted value is preserved. New plaintext keys are encrypted
|
||||
* via Peptide_News_Encryption before being written to wp_options.
|
||||
*
|
||||
* @param string $value Raw input from the settings form.
|
||||
* @param string $option_name The wp_options key for this API key.
|
||||
* @return string Encrypted API key or existing stored value.
|
||||
*/
|
||||
public function sanitize_api_key( string $value, string $option_name ): string {
|
||||
$value = sanitize_text_field( $value );
|
||||
if ( empty( $value ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If masked format detected (starts with bullets), keep existing stored value.
|
||||
if ( mb_strpos( $value, '••••••' ) === 0 ) {
|
||||
return get_option( $option_name, '' );
|
||||
}
|
||||
|
||||
// Encrypt the new plaintext key before storing.
|
||||
if ( class_exists( 'Peptide_News_Encryption' ) ) {
|
||||
return Peptide_News_Encryption::encrypt( $value );
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user