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:
96
includes/admin/class-admin-notices.php
Executable file
96
includes/admin/class-admin-notices.php
Executable 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' )
|
||||
);
|
||||
}
|
||||
}
|
||||
207
includes/admin/class-admin-page.php
Executable file
207
includes/admin/class-admin-page.php
Executable 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 );
|
||||
}
|
||||
}
|
||||
67
includes/admin/class-dashboard-widget.php
Executable file
67
includes/admin/class-dashboard-widget.php
Executable 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' )
|
||||
);
|
||||
}
|
||||
}
|
||||
84
includes/admin/class-log-viewer.php
Executable file
84
includes/admin/class-log-viewer.php
Executable 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
|
||||
),
|
||||
] );
|
||||
}
|
||||
}
|
||||
46
includes/admin/class-metrics-page.php
Executable file
46
includes/admin/class-metrics-page.php
Executable 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';
|
||||
}
|
||||
}
|
||||
54
includes/admin/class-post-metabox.php
Executable file
54
includes/admin/class-post-metabox.php
Executable 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';
|
||||
}
|
||||
}
|
||||
203
includes/admin/class-review-queue.php
Executable file
203
includes/admin/class-review-queue.php
Executable 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 ] );
|
||||
}
|
||||
}
|
||||
291
includes/admin/class-settings-fields.php
Executable file
291
includes/admin/class-settings-fields.php
Executable 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' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user