Initial commit: AutoBlogger v0.1.0

WordPress plugin that auto-researches topics from Reddit, generates
SEO-optimized articles via OpenRouter LLMs, runs them through a chief
editor agent, and publishes on a daily schedule.

Includes:
- Full generation pipeline (collect → analyze → score → generate → review → publish)
- Cost tracking and budget enforcement
- Admin UI: settings, review queue, metrics, activity log
- CI/CD pipeline (GitHub Actions → WordPress REST deploy)
- Unit test suite (PHPUnit + Brain Monkey)
- Structured logging with configurable verbosity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Terence Ong
2026-04-10 16:10:27 +07:00
commit 68c6f67bcf
65 changed files with 8833 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Displays admin notices for onboarding, errors, and budget warnings.
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_notices`.
* Dependencies: Autoblogger_Cost_Tracker (for budget warnings).
*
* @see class-autoblogger.php — Registers the hook.
*/
class Autoblogger_Admin_Notices {
/**
* Display relevant admin notices.
*
* @return void
*/
public function on_display_notices(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$this->check_api_key_notice();
$this->check_budget_notice();
$this->check_subreddit_notice();
}
/**
* Show a notice if the OpenRouter API key is not configured.
*
* @return void
*/
private function check_api_key_notice(): void {
$api_key = get_option( 'autoblogger_openrouter_api_key', '' );
if ( '' !== $api_key ) {
return;
}
printf(
'<div class="notice notice-warning is-dismissible"><p>%s <a href="%s">%s</a></p></div>',
esc_html__( 'AutoBlogger: OpenRouter API key is not configured. Content generation is disabled.', 'autoblogger' ),
esc_url( admin_url( 'admin.php?page=autoblogger-settings' ) ),
esc_html__( 'Configure now', 'autoblogger' )
);
}
/**
* Show a warning if monthly budget is approaching or exceeded.
*
* @return void
*/
private function check_budget_notice(): void {
$cost_tracker = new Autoblogger_Cost_Tracker();
$utilization = $cost_tracker->get_budget_utilization();
if ( $utilization >= 100.0 ) {
printf(
'<div class="notice notice-error"><p>%s</p></div>',
esc_html__( 'AutoBlogger: Monthly API budget EXCEEDED. Content generation is paused until next month or budget increase.', 'autoblogger' )
);
} elseif ( $utilization >= 80.0 ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html( sprintf(
/* translators: %s: budget utilization percentage */
__( 'AutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'autoblogger' ),
$utilization
) )
);
}
}
/**
* Show a notice if no subreddits are configured.
*
* @return void
*/
private function check_subreddit_notice(): void {
$subreddits = json_decode( get_option( 'autoblogger_target_subreddits', '[]' ), true );
if ( ! empty( $subreddits ) ) {
return;
}
// Only show on our settings page.
$screen = get_current_screen();
if ( ! $screen || 'toplevel_page_autoblogger-settings' !== $screen->id ) {
return;
}
printf(
'<div class="notice notice-info is-dismissible"><p>%s</p></div>',
esc_html__( 'AutoBlogger: No subreddits configured. Add target subreddits in Source Configuration to start collecting data.', 'autoblogger' )
);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/**
* Registers and renders the main AutoBlogger settings page in wp-admin.
*
* Uses the WordPress Settings API for option registration, sanitization, and rendering.
* Settings are defined as a declarative array — adding a new setting is one array entry.
*
* Triggered by: Autoblogger::register_admin_hooks() registers menu and settings hooks.
* Dependencies: Autoblogger_Encryption (for API key storage), Autoblogger_Settings_Fields.
*
* @see class-autoblogger.php — Registers hooks that call this class.
* @see class-settings-fields.php — Defines all settings fields and sections.
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
*/
class Autoblogger_Admin_Page {
private const PAGE_SLUG = 'autoblogger-settings';
private const OPTION_GROUP = 'autoblogger_settings_group';
/** Register the top-level AutoBlogger menu item. */
public function on_register_menu(): void {
add_menu_page(
__( 'AutoBlogger', 'autoblogger' ),
__( 'AutoBlogger', 'autoblogger' ),
'manage_options',
self::PAGE_SLUG,
[ $this, 'render_page' ],
'dashicons-edit-page',
30
);
}
/** Register all settings with the WordPress Settings API. */
public function on_register_settings(): void {
$sections = Autoblogger_Settings_Fields::get_sections();
$fields = Autoblogger_Settings_Fields::get_fields();
foreach ( $sections as $section_id => $section ) {
add_settings_section( $section_id, $section['title'], '__return_empty_string', self::PAGE_SLUG );
}
foreach ( $fields as $field ) {
$section = $field['section'] ?? 'autoblogger_api';
register_setting( self::OPTION_GROUP, $field['id'], [
'type' => $field['wp_type'] ?? 'string',
'sanitize_callback' => [ $this, 'sanitize_field' ],
'default' => $field['default'] ?? '',
] );
add_settings_field( $field['id'], $field['label'], [ $this, 'render_field' ], self::PAGE_SLUG, $section, $field );
}
}
/** Enqueue admin CSS and JS on all AutoBlogger admin pages. */
public function on_enqueue_assets( string $hook_suffix ): void {
$pages = [
'toplevel_page_' . self::PAGE_SLUG,
'autoblogger_page_autoblogger-metrics',
'autoblogger_page_autoblogger-review-queue',
'autoblogger_page_autoblogger-logs',
];
if ( ! in_array( $hook_suffix, $pages, true ) ) {
return;
}
wp_enqueue_style( 'autoblogger-admin', AUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], AUTOBLOGGER_VERSION );
wp_enqueue_script( 'autoblogger-admin', AUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], AUTOBLOGGER_VERSION, true );
wp_localize_script( 'autoblogger-admin', 'autobloggerAdmin', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'generateNonce' => wp_create_nonce( 'autoblogger_generate_now' ),
'testNonce' => wp_create_nonce( 'autoblogger_test_connection' ),
'reviewNonce' => wp_create_nonce( 'autoblogger_review_queue' ),
'generatingText' => __( 'Generating...', 'autoblogger' ),
'generateText' => __( 'Generate Now', 'autoblogger' ),
'testingText' => __( 'Testing...', 'autoblogger' ),
'testText' => __( 'Test Connections', 'autoblogger' ),
] );
}
/** Render the settings page (delegates to template). */
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$sections = Autoblogger_Settings_Fields::get_sections();
$fields = Autoblogger_Settings_Fields::get_fields();
include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/settings-page.php';
}
/**
* Render a single settings field based on its type.
*
* @param array<string, mixed> $args Field definition.
*/
public function render_field( array $args ): void {
$id = esc_attr( $args['id'] );
$type = $args['type'] ?? 'text';
$default = $args['default'] ?? '';
$desc = $args['description'] ?? '';
$value = get_option( $args['id'], $default );
if ( 'password' === $type && '' !== $value ) {
$decrypted = Autoblogger_Encryption::decrypt( $value );
$value = '' !== $decrypted ? '••••••••' : '';
}
switch ( $type ) {
case 'text':
case 'number':
case 'url':
case 'time':
$attrs = '';
if ( isset( $args['min'] ) ) { $attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; }
if ( isset( $args['max'] ) ) { $attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; }
if ( isset( $args['step'] ) ) { $attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; }
printf( '<input type="%s" id="%s" name="%s" value="%s" class="ab-input" %s />', esc_attr( $type ), $id, $id, esc_attr( (string) $value ), $attrs );
break;
case 'password':
printf( '<input type="password" id="%s" name="%s" value="" class="ab-input" placeholder="%s" autocomplete="off" />',
$id, $id, '' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'autoblogger' ) : '' );
break;
case 'textarea':
printf( '<textarea id="%s" name="%s" rows="3" class="ab-textarea">%s</textarea>', $id, $id, esc_textarea( (string) $value ) );
break;
case 'select':
printf( '<select id="%s" name="%s" class="ab-select">', $id, $id );
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
printf( '<option value="%s"%s>%s</option>', esc_attr( (string) $v ), selected( $value, (string) $v, false ), esc_html( $label ) );
}
echo '</select>';
break;
case 'toggle':
$checked = in_array( $value, [ '1', 'yes', true ], true );
printf( '<label class="ab-toggle"><input type="hidden" name="%s" value="0" /><input type="checkbox" name="%s" value="1" %s /><span class="ab-toggle-slider"></span></label>',
$id, $id, checked( $checked, true, false ) );
break;
case 'checkboxes':
$current = json_decode( (string) $value, true ) ?: [];
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
$is_checked = in_array( $v, $current, true );
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
printf( '<label class="ab-checkbox-label"><input type="checkbox" name="%s[]" value="%s" %s%s /> %s</label>',
$id, esc_attr( $v ), checked( $is_checked, true, false ), $disabled, esc_html( $label ) );
}
break;
case 'author_select':
wp_dropdown_users( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto (first admin) —', 'autoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select' ] );
break;
case 'category_select':
wp_dropdown_categories( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto-assign by type —', 'autoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select', 'hide_empty' => false ] );
break;
}
if ( isset( $args['badge'] ) ) {
printf( ' <span class="ab-badge">%s</span>', esc_html( $args['badge'] ) );
}
if ( '' !== $desc ) {
printf( '<p class="ab-field-desc">%s</p>', esc_html( $desc ) );
}
}
/**
* Sanitize a settings field value.
*
* @param mixed $value The submitted value.
* @return mixed Sanitized value.
*/
public function sanitize_field( $value ) {
$option_name = '';
$filter = current_filter();
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
}
$encrypted = [ 'autoblogger_openrouter_api_key', 'autoblogger_reddit_client_secret', 'autoblogger_ga4_credentials_json' ];
if ( in_array( $option_name, $encrypted, true ) ) {
if ( '' === $value ) { return get_option( $option_name, '' ); }
return Autoblogger_Encryption::encrypt( sanitize_text_field( $value ) );
}
$json_fields = [ 'autoblogger_target_subreddits', 'autoblogger_topic_exclusions', 'autoblogger_enabled_sources' ];
if ( in_array( $option_name, $json_fields, true ) ) {
if ( is_array( $value ) ) { return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) ); }
if ( is_string( $value ) && '[' !== substr( trim( $value ), 0, 1 ) ) {
$items = array_filter( array_map( 'trim', explode( ',', $value ) ) );
return wp_json_encode( array_values( $items ) );
}
return sanitize_text_field( $value );
}
$numeric = [ 'autoblogger_daily_article_target', 'autoblogger_monthly_budget_usd', 'autoblogger_min_word_count', 'autoblogger_max_word_count', 'autoblogger_default_author', 'autoblogger_default_category' ];
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}
return sanitize_text_field( (string) $value );
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* WordPress Dashboard widget showing AutoBlogger generation status at a glance.
*
* Displays: last generation time, articles generated today, monthly cost,
* and budget utilization.
*
* Triggered by: Could be registered via wp_dashboard_setup hook.
* Dependencies: Autoblogger_Cost_Tracker.
*
* @see class-autoblogger.php — Can register this on wp_dashboard_setup.
*/
class Autoblogger_Dashboard_Widget {
/**
* Register the dashboard widget.
*
* @return void
*/
public function on_register_widget(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
wp_add_dashboard_widget(
'autoblogger_status',
__( 'AutoBlogger Status', 'autoblogger' ),
[ $this, 'render_widget' ]
);
}
/**
* Render the widget content.
*
* @return void
*/
public function render_widget(): void {
$cost_tracker = new Autoblogger_Cost_Tracker();
$monthly_spend = $cost_tracker->get_monthly_spend();
$budget = (float) get_option( 'autoblogger_monthly_budget_usd', 50.00 );
$utilization = $cost_tracker->get_budget_utilization();
$next_run = wp_next_scheduled( 'autoblogger_daily_generation' );
printf( '<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
esc_html__( 'Monthly Spend:', 'autoblogger' ),
$monthly_spend,
$budget,
$utilization
);
if ( false !== $next_run ) {
printf( '<p><strong>%s</strong> %s</p>',
esc_html__( 'Next Generation:', 'autoblogger' ),
esc_html( wp_date( 'M j, Y g:i A', $next_run ) )
);
}
printf(
'<p><a href="%s" class="button button-secondary">%s</a></p>',
esc_url( admin_url( 'admin.php?page=autoblogger-metrics' ) ),
esc_html__( 'View Full Metrics', 'autoblogger' )
);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* Admin page displaying structured application log entries.
*
* Provides level filtering (error/warning/info/debug), text search,
* pagination, and a one-click "Clear Old Logs" action. Reads from the
* ab_event_log table via Autoblogger_Logger::query().
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_menu`.
* Dependencies: Autoblogger_Logger.
*
* @see core/class-logger.php — Writes log entries and provides query().
* @see templates/admin/log-viewer.php — Renders the HTML.
* @see class-autoblogger.php — Registers the AJAX clear action.
*/
class Autoblogger_Log_Viewer {
/**
* Register the log viewer submenu page under AutoBlogger.
*
* @return void
*/
public function on_register_menu(): void {
add_submenu_page(
'autoblogger-settings',
__( 'Activity Log', 'autoblogger' ),
__( 'Activity Log', 'autoblogger' ),
'manage_options',
'autoblogger-logs',
[ $this, 'render_page' ]
);
}
/**
* Render the log viewer page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$level = isset( $_GET['level'] ) ? sanitize_key( $_GET['level'] ) : 'all';
$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$result = Autoblogger_Logger::query( $level, $search, $paged, 50 );
$rows = $result['rows'];
$total = $result['total'];
$total_pages = (int) ceil( $total / 50 );
include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/log-viewer.php';
}
/**
* AJAX handler: clear logs older than 30 days.
*
* Side effects: deletes rows from ab_event_log.
*
* @return void
*/
public function on_ajax_clear_logs(): void {
check_ajax_referer( 'autoblogger_clear_logs', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'autoblogger' ) ], 403 );
return;
}
$days = isset( $_POST['days'] ) ? absint( $_POST['days'] ) : 30;
$deleted = Autoblogger_Logger::prune( $days );
wp_send_json_success( [
'message' => sprintf(
/* translators: %d: number of entries deleted */
__( '%d log entries cleared.', 'autoblogger' ),
$deleted
),
] );
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Admin page displaying cost dashboard, content scores, and generation history.
*
* Shows: monthly spend, daily cost chart, spend by pipeline stage, budget
* utilization, top/bottom performing posts, and recent generation logs.
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_menu`.
* Dependencies: Autoblogger_Cost_Tracker.
*
* @see class-autoblogger.php — Registers the hook.
* @see core/class-cost-tracker.php — Provides cost data.
*/
class Autoblogger_Metrics_Page {
/**
* Register the metrics submenu page under AutoBlogger.
*
* @return void
*/
public function on_register_menu(): void {
add_submenu_page(
'autoblogger-settings',
__( 'Metrics & Costs', 'autoblogger' ),
__( 'Metrics & Costs', 'autoblogger' ),
'manage_options',
'autoblogger-metrics',
[ $this, 'render_page' ]
);
}
/**
* Render the metrics page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metrics-page.php';
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/**
* Adds a metabox to posts showing AutoBlogger generation metadata.
*
* Only appears on posts that were generated by AutoBlogger (checked via
* _autoblogger_generated post meta).
*
* Triggered by: Autoblogger::register_admin_hooks() on `add_meta_boxes`.
* Dependencies: WordPress post meta API.
*
* @see class-autoblogger.php — Registers the hook.
* @see core/class-publisher.php — Writes the metadata this class displays.
*/
class Autoblogger_Post_Metabox {
/**
* Register the metabox for the post editor.
*
* @return void
*/
public function on_register_metabox(): void {
$screen = get_current_screen();
if ( ! $screen || 'post' !== $screen->id ) {
return;
}
global $post;
if ( ! $post || '1' !== get_post_meta( $post->ID, '_autoblogger_generated', true ) ) {
return;
}
add_meta_box(
'autoblogger_generation_info',
__( 'AutoBlogger Generation Info', 'autoblogger' ),
[ $this, 'render_metabox' ],
'post',
'side',
'default'
);
}
/**
* Render the generation metadata metabox.
*
* @param \WP_Post $post
*
* @return void
*/
public function render_metabox( \WP_Post $post ): void {
include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php';
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
/**
* Admin page listing generated draft posts for editorial review.
*
* Provides approve (publish), edit (opens WP editor), and reject (trash)
* actions on each draft. Supports bulk approve/reject via checkboxes.
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_menu`.
* Dependencies: WordPress WP_Query, post meta API.
*
* @see class-autoblogger.php — Registers the hook and AJAX handlers.
* @see core/class-publisher.php — Creates the drafts this page displays.
* @see templates/admin/review-queue.php — Renders the HTML.
*/
class Autoblogger_Review_Queue {
/**
* Register the review queue submenu page under AutoBlogger.
*
* @return void
*/
public function on_register_menu(): void {
$hook = add_submenu_page(
'autoblogger-settings',
__( 'Review Queue', 'autoblogger' ),
__( 'Review Queue', 'autoblogger' ),
'manage_options',
'autoblogger-review-queue',
[ $this, 'render_page' ]
);
if ( false !== $hook ) {
add_action( "load-{$hook}", [ $this, 'on_handle_bulk_actions' ] );
}
}
/**
* Render the review queue page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$query = $this->get_pending_posts( $paged );
include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/review-queue.php';
}
/**
* Query generated draft posts awaiting review.
*
* @param int $paged Page number.
*
* @return \WP_Query
*/
public function get_pending_posts( int $paged = 1 ): \WP_Query {
return new \WP_Query( [
'post_type' => 'post',
'post_status' => 'draft',
'posts_per_page' => 20,
'paged' => $paged,
'meta_query' => [
[
'key' => '_autoblogger_generated',
'value' => '1',
],
],
'orderby' => 'date',
'order' => 'DESC',
] );
}
/**
* Handle bulk approve/reject actions submitted via form POST.
*
* Verifies nonce and capability before processing. Redirects back with
* a status message query arg.
*
* Side effects: changes post status, may trash posts.
*
* @return void
*/
public function on_handle_bulk_actions(): void {
if ( ! isset( $_POST['autoblogger_bulk_action'] ) ) {
return;
}
check_admin_referer( 'autoblogger_review_queue_bulk', 'autoblogger_review_nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$action = sanitize_text_field( wp_unslash( $_POST['autoblogger_bulk_action'] ) );
$post_ids = isset( $_POST['autoblogger_post_ids'] ) && is_array( $_POST['autoblogger_post_ids'] )
? array_map( 'absint', $_POST['autoblogger_post_ids'] )
: [];
if ( empty( $post_ids ) ) {
return;
}
$count = 0;
foreach ( $post_ids as $post_id ) {
if ( $post_id <= 0 ) {
continue;
}
if ( 'approve' === $action ) {
$result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true );
if ( ! is_wp_error( $result ) ) {
update_post_meta( $post_id, '_autoblogger_approved_at', gmdate( 'c' ) );
$count++;
}
} elseif ( 'reject' === $action ) {
$result = wp_trash_post( $post_id );
if ( false !== $result ) {
$count++;
}
}
}
$redirect = add_query_arg(
[
'page' => 'autoblogger-review-queue',
'autoblogger_bulk_done' => $count,
'autoblogger_bulk_type' => $action,
],
admin_url( 'admin.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* AJAX handler: approve a single post (publish it).
*
* Side effects: changes post status to 'publish'.
*
* @return void
*/
public function on_ajax_approve_post(): void {
check_ajax_referer( 'autoblogger_review_queue', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'autoblogger' ) ], 403 );
return;
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
if ( $post_id <= 0 ) {
wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'autoblogger' ) ] );
return;
}
$result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true );
if ( is_wp_error( $result ) ) {
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
return;
}
update_post_meta( $post_id, '_autoblogger_approved_at', gmdate( 'c' ) );
wp_send_json_success( [ 'message' => __( 'Post published.', 'autoblogger' ), 'post_id' => $post_id ] );
}
/**
* AJAX handler: reject a single post (trash it).
*
* Side effects: trashes the post.
*
* @return void
*/
public function on_ajax_reject_post(): void {
check_ajax_referer( 'autoblogger_review_queue', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'autoblogger' ) ], 403 );
return;
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
if ( $post_id <= 0 ) {
wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'autoblogger' ) ] );
return;
}
$result = wp_trash_post( $post_id );
if ( false === $result || null === $result ) {
wp_send_json_error( [ 'message' => __( 'Failed to reject post.', 'autoblogger' ) ] );
return;
}
wp_send_json_success( [ 'message' => __( 'Post rejected.', 'autoblogger' ), 'post_id' => $post_id ] );
}
}

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
/**
* Declarative settings fields and sections for the AutoBlogger admin page.
*
* Centralizes all field and section definitions in one place, making it trivial
* to add new settings (just add one array entry). Decoupled from page rendering logic.
*
* Triggered by: Autoblogger_Admin_Page::on_register_settings() calls static methods here.
* Dependencies: None — contains only data definitions and static methods.
*
* @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings.
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
*/
class Autoblogger_Settings_Fields {
/**
* Get all settings sections. Each section maps to a tab in the admin UI.
*
* @return array<string, array{title: string, icon: string, description: string}>
*/
public static function get_sections(): array {
return [
'autoblogger_api' => [
'title' => __( 'API Keys', 'autoblogger' ),
'icon' => 'dashicons-admin-network',
'description' => __( 'Connect your external services. Keys are encrypted at rest.', 'autoblogger' ),
],
'autoblogger_models' => [
'title' => __( 'AI Models', 'autoblogger' ),
'icon' => 'dashicons-superhero-alt',
'description' => __( 'Choose which models power each stage of the pipeline.', 'autoblogger' ),
],
'autoblogger_content' => [
'title' => __( 'Content', 'autoblogger' ),
'icon' => 'dashicons-edit-large',
'description' => __( 'Control tone, length, pipeline mode, and topic guardrails.', 'autoblogger' ),
],
'autoblogger_sources' => [
'title' => __( 'Sources', 'autoblogger' ),
'icon' => 'dashicons-rss',
'description' => __( 'Configure where AutoBlogger finds trending topics.', 'autoblogger' ),
],
'autoblogger_schedule' => [
'title' => __( 'Schedule & Budget', 'autoblogger' ),
'icon' => 'dashicons-calendar-alt',
'description' => __( 'Set daily generation schedule, volume, and spending limits.', 'autoblogger' ),
],
'autoblogger_publishing' => [
'title' => __( 'Publishing', 'autoblogger' ),
'icon' => 'dashicons-megaphone',
'description' => __( 'Control how generated content is published.', 'autoblogger' ),
],
'autoblogger_analytics' => [
'title' => __( 'Analytics', 'autoblogger' ),
'icon' => 'dashicons-chart-area',
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'autoblogger' ),
],
];
}
/**
* Get all settings fields. Adding a new setting = adding one entry to this array.
*
* @return array<int, array<string, mixed>>
*/
public static function get_fields(): array {
return [
// ── API Keys ────────────────────────────────────────────────
[
'id' => 'autoblogger_openrouter_api_key',
'label' => __( 'OpenRouter API Key', 'autoblogger' ),
'type' => 'password',
'section' => 'autoblogger_api',
'description' => __( 'Get your key at openrouter.ai/keys', 'autoblogger' ),
'icon' => '🔑',
],
[
'id' => 'autoblogger_reddit_client_id',
'label' => __( 'Reddit Client ID', 'autoblogger' ),
'type' => 'text',
'section' => 'autoblogger_api',
'description' => __( 'Create a script app at reddit.com/prefs/apps', 'autoblogger' ),
'icon' => '🤖',
],
[
'id' => 'autoblogger_reddit_client_secret',
'label' => __( 'Reddit Client Secret', 'autoblogger' ),
'type' => 'password',
'section' => 'autoblogger_api',
],
// ── AI Models ───────────────────────────────────────────────
[
'id' => 'autoblogger_analysis_model',
'label' => __( 'Analysis Model', 'autoblogger' ),
'type' => 'text',
'section' => 'autoblogger_models',
'default' => AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'autoblogger' ),
'badge' => __( 'Low cost', 'autoblogger' ),
],
[
'id' => 'autoblogger_writing_model',
'label' => __( 'Writing Model', 'autoblogger' ),
'type' => 'text',
'section' => 'autoblogger_models',
'default' => AUTOBLOGGER_DEFAULT_WRITING_MODEL,
'description' => __( 'Used for article generation. Quality matters here.', 'autoblogger' ),
'badge' => __( 'Quality', 'autoblogger' ),
],
[
'id' => 'autoblogger_editor_model',
'label' => __( 'Editor Model', 'autoblogger' ),
'type' => 'text',
'section' => 'autoblogger_models',
'default' => AUTOBLOGGER_DEFAULT_EDITOR_MODEL,
'description' => __( 'Used for the chief editor review pass.', 'autoblogger' ),
'badge' => __( 'Quality', 'autoblogger' ),
],
// ── Content ─────────────────────────────────────────────────
[
'id' => 'autoblogger_niche_description',
'label' => __( 'Niche Description', 'autoblogger' ),
'type' => 'textarea',
'section' => 'autoblogger_content',
'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'autoblogger' ),
],
[
'id' => 'autoblogger_tone',
'label' => __( 'Content Tone', 'autoblogger' ),
'type' => 'select',
'section' => 'autoblogger_content',
'default' => 'informational',
'options' => [
'informational' => __( 'Informational', 'autoblogger' ),
'conversational' => __( 'Conversational', 'autoblogger' ),
'professional' => __( 'Professional', 'autoblogger' ),
'casual' => __( 'Casual', 'autoblogger' ),
'authoritative' => __( 'Authoritative', 'autoblogger' ),
],
],
[
'id' => 'autoblogger_writing_pipeline',
'label' => __( 'Writing Pipeline', 'autoblogger' ),
'type' => 'select',
'section' => 'autoblogger_content',
'default' => 'multi_step',
'options' => [
'multi_step' => __( 'Multi-step (outline → draft → polish)', 'autoblogger' ),
'single_pass' => __( 'Single-pass (one LLM call)', 'autoblogger' ),
],
],
[
'id' => 'autoblogger_min_word_count',
'label' => __( 'Min Word Count', 'autoblogger' ),
'type' => 'number',
'section' => 'autoblogger_content',
'default' => 800,
'min' => 200,
],
[
'id' => 'autoblogger_max_word_count',
'label' => __( 'Max Word Count', 'autoblogger' ),
'type' => 'number',
'section' => 'autoblogger_content',
'default' => 2000,
'min' => 500,
],
[
'id' => 'autoblogger_topic_exclusions',
'label' => __( 'Topic Exclusions', 'autoblogger' ),
'type' => 'textarea',
'section' => 'autoblogger_content',
'description' => __( 'Comma-separated topics to never write about.', 'autoblogger' ),
],
// ── Sources ─────────────────────────────────────────────────
[
'id' => 'autoblogger_target_subreddits',
'label' => __( 'Target Subreddits', 'autoblogger' ),
'type' => 'textarea',
'section' => 'autoblogger_sources',
'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'autoblogger' ),
],
[
'id' => 'autoblogger_enabled_sources',
'label' => __( 'Enabled Sources', 'autoblogger' ),
'type' => 'checkboxes',
'section' => 'autoblogger_sources',
'options' => [
'reddit' => __( 'Reddit', 'autoblogger' ),
'tiktok' => __( 'TikTok (coming soon)', 'autoblogger' ),
'instagram' => __( 'Instagram (coming soon)', 'autoblogger' ),
'youtube' => __( 'YouTube (coming soon)', 'autoblogger' ),
],
'default' => '["reddit"]',
'description' => __( 'Select which platforms to monitor for topics.', 'autoblogger' ),
],
// ── Schedule & Budget ───────────────────────────────────────
[
'id' => 'autoblogger_daily_article_target',
'label' => __( 'Articles per Day', 'autoblogger' ),
'type' => 'number',
'section' => 'autoblogger_schedule',
'default' => 1,
'min' => 1,
'max' => 10,
],
[
'id' => 'autoblogger_schedule_time',
'label' => __( 'Generation Time', 'autoblogger' ),
'type' => 'time',
'section' => 'autoblogger_schedule',
'default' => '03:00',
'description' => __( 'Daily generation runs at this time (server timezone).', 'autoblogger' ),
],
[
'id' => 'autoblogger_monthly_budget_usd',
'label' => __( 'Monthly Budget (USD)', 'autoblogger' ),
'type' => 'number',
'section' => 'autoblogger_schedule',
'default' => 50,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'autoblogger' ),
],
// ── Publishing ──────────────────────────────────────────────
[
'id' => 'autoblogger_auto_publish',
'label' => __( 'Auto-Publish', 'autoblogger' ),
'type' => 'toggle',
'section' => 'autoblogger_publishing',
'default' => '0',
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'autoblogger' ),
],
[
'id' => 'autoblogger_default_author',
'label' => __( 'Default Author', 'autoblogger' ),
'type' => 'author_select',
'section' => 'autoblogger_publishing',
'default' => 0,
'description' => __( 'WordPress user assigned as author of generated posts.', 'autoblogger' ),
],
[
'id' => 'autoblogger_default_category',
'label' => __( 'Default Category', 'autoblogger' ),
'type' => 'category_select',
'section' => 'autoblogger_publishing',
'default' => 0,
'description' => __( 'Fallback category for generated posts.', 'autoblogger' ),
],
// ── Logging ─────────────────────────────────────────────────
[
'id' => 'autoblogger_log_level',
'label' => __( 'Log Level', 'autoblogger' ),
'type' => 'select',
'section' => 'autoblogger_publishing',
'default' => 'info',
'options' => [
'error' => __( 'Error — only failures', 'autoblogger' ),
'warning' => __( 'Warning — errors + warnings', 'autoblogger' ),
'info' => __( 'Info — key events (recommended)', 'autoblogger' ),
'debug' => __( 'Debug — everything (verbose)', 'autoblogger' ),
],
'description' => __( 'Controls detail level in the Activity Log.', 'autoblogger' ),
],
// ── Analytics ───────────────────────────────────────────────
[
'id' => 'autoblogger_ga4_property_id',
'label' => __( 'GA4 Property ID', 'autoblogger' ),
'type' => 'text',
'section' => 'autoblogger_analytics',
'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'autoblogger' ),
],
[
'id' => 'autoblogger_ga4_credentials_json',
'label' => __( 'Service Account JSON', 'autoblogger' ),
'type' => 'password',
'section' => 'autoblogger_analytics',
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'autoblogger' ),
],
];
}
}