diff --git a/CHANGELOG.md b/CHANGELOG.md index dddb2c0..1f11be6 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.6.0] — 2026-04-20 + +### Added +- **Generate article from idea.** New "Generate" column in the Ideas browser. + Click any idea's Generate button to produce an article directly, bypassing + the scheduled pipeline's collect → analyze → score steps. The button shows + real-time stage updates (drafting, editing, publishing) via per-idea status + polling, then swaps to Edit/View links with cost on completion. + - `Ideas_Browser::on_ajax_generate_from_idea()` — AJAX trigger that stores + the idea and schedules a one-shot WP-Cron event. + - `Ideas_Browser::on_cron_generate_from_idea()` — Background handler that + runs Article_Worker for the selected idea. + - `Ideas_Browser::on_ajax_idea_gen_status()` — Per-idea status polling. + - `assets/js/ideas-browser.js` — Button click, AJAX, polling, and UI state. + - Per-idea transients (`prab_idea_gen_{id}`) for independent status tracking. + - Page-load polling resume for ideas that were mid-generation. + - Generation lock respected — only one generation at a time. + ## [0.5.3] — 2026-04-20 ### Fixed diff --git a/assets/js/ideas-browser.js b/assets/js/ideas-browser.js new file mode 100644 index 0000000..c393a2e --- /dev/null +++ b/assets/js/ideas-browser.js @@ -0,0 +1,157 @@ +/** + * PRAutoBlogger Ideas Browser — "Generate Article" button handler. + * + * Each idea row has a Generate button that triggers single-article generation + * via AJAX → WP-Cron background job. Polls for status and shows stage updates + * in the button cell, then swaps to a "View" link on completion. + * + * @see admin/class-ideas-browser.php — AJAX handlers. + * @see templates/admin/ideas-browser.php — Button markup. + */ +(function ($) { + 'use strict'; + + var config = window.prautobloggerAdmin || {}; + var pollTimers = {}; + + /** + * Kick off generation for an idea and start polling. + * + * @param {jQuery} $cell The table cell containing the button. + * @param {number} ideaId Analysis result row ID. + */ + function generateFromIdea($cell, ideaId) { + $cell.html( + ' ' + + 'Starting…' + ); + + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'prautoblogger_generate_from_idea', + nonce: config.ideaGenNonce, + idea_id: ideaId + }, + timeout: 15000, + success: function (response) { + if (response.success) { + pollIdeaStatus($cell, ideaId); + } else { + showIdeaError($cell, ideaId, response.data && response.data.message || 'Failed to start.'); + } + }, + error: function () { + showIdeaError($cell, ideaId, 'Request failed.'); + } + }); + } + + /** + * Poll the per-idea status endpoint until generation completes. + * + * @param {jQuery} $cell The table cell to update. + * @param {number} ideaId Analysis result row ID. + */ + function pollIdeaStatus($cell, ideaId) { + pollTimers[ideaId] = setInterval(function () { + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'prautoblogger_idea_gen_status', + nonce: config.ideaGenNonce, + idea_id: ideaId + }, + timeout: 10000, + success: function (response) { + if (!response.success) return; + var d = response.data; + + if (d.status === 'running') { + $cell.find('.prab-idea-stage').text(d.stage || 'Generating…'); + return; + } + + // Terminal — stop polling. + clearInterval(pollTimers[ideaId]); + delete pollTimers[ideaId]; + + if (d.status === 'complete') { + showIdeaComplete($cell, ideaId, d); + } else if (d.status === 'error') { + showIdeaError($cell, ideaId, d.message || 'Generation failed.'); + } else { + showIdeaError($cell, ideaId, 'Unknown status.'); + } + } + }); + }, 4000); + } + + /** + * Show the completion state with a View link. + * + * @param {jQuery} $cell The table cell. + * @param {number} ideaId Analysis result row ID. + * @param {Object} data Server response data. + */ + function showIdeaComplete($cell, ideaId, data) { + var html = ' '; + html += 'Done'; + + if (data.post_id) { + var editUrl = config.adminUrl + 'post.php?post=' + data.post_id + '&action=edit'; + var viewUrl = config.adminUrl + '?p=' + data.post_id; + html += ' — Edit'; + html += ' | View'; + } + + if (data.cost > 0) { + html += '
$' + parseFloat(data.cost).toFixed(4) + '
'; + } + + $cell.html(html); + } + + /** + * Show an error state with a retry button. + * + * @param {jQuery} $cell The table cell. + * @param {number} ideaId Analysis result row ID. + * @param {string} message Error message. + */ + function showIdeaError($cell, ideaId, message) { + $cell.html( + ' ' + + '' + $('').text(message).html() + '' + + '
' + ); + } + + // Click handler for Generate / Retry buttons (event delegation). + $(document).on('click', '.prab-gen-idea-btn', function () { + var $btn = $(this); + if ($btn.prop('disabled')) return; + + var ideaId = $btn.data('idea-id'); + var $cell = $btn.closest('td'); + + generateFromIdea($cell, ideaId); + }); + + // On page load, resume polling for any ideas that are mid-generation. + $(function () { + $('.prab-idea-gen-cell[data-status="running"]').each(function () { + var $cell = $(this); + var ideaId = $cell.data('idea-id'); + $cell.html( + ' ' + + 'Resuming…' + ); + pollIdeaStatus($cell, ideaId); + }); + }); + +})(jQuery); diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index f6f319e..f080879 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -72,11 +72,13 @@ class PRAutoBlogger_Admin_Page { wp_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'adminUrl' => admin_url(), 'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ), 'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ), 'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ), 'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ), 'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ), + 'ideaGenNonce' => wp_create_nonce( 'prautoblogger_idea_gen' ), 'generatingText' => __( 'Generating...', 'prautoblogger' ), 'generateText' => __( 'Generate Now', 'prautoblogger' ), 'testingText' => __( 'Testing...', 'prautoblogger' ), @@ -84,6 +86,11 @@ class PRAutoBlogger_Admin_Page { // Image models for the picker — defined in the settings class to keep this file short. 'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(), ] ); + + // Ideas browser: generate-from-idea JS (only on the Ideas page). + if ( 'prautoblogger_page_prautoblogger-ideas' === $hook_suffix ) { + wp_enqueue_script( 'prautoblogger-ideas-browser', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/ideas-browser.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true ); + } } /** Render the settings page (delegates to template). */ diff --git a/includes/admin/class-ideas-browser.php b/includes/admin/class-ideas-browser.php index e80e87e..7f64510 100644 --- a/includes/admin/class-ideas-browser.php +++ b/includes/admin/class-ideas-browser.php @@ -2,30 +2,25 @@ declare(strict_types=1); /** - * Admin page listing analysis results (article ideas) from all pipeline runs. - * - * Shows every idea the content analyzer has surfaced: topic, type, relevance - * score, frequency, source, and metadata (key points, keywords). Filterable - * by type and source, with pagination. Gives the CEO a live view of the idea - * pipeline without needing to trigger a generation run. + * Admin page listing analysis results (article ideas) with per-idea generation. * * Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`. - * Dependencies: WordPress $wpdb for direct table queries. + * Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock. * - * @see core/class-content-analyzer.php — Produces the rows this page displays. - * @see models/class-analysis-result.php — Data model for analysis results. + * @see core/class-article-worker.php — Generates articles from ideas. * @see templates/admin/ideas-browser.php — Renders the HTML. - * @see ARCHITECTURE.md — Data flow step 2. */ class PRAutoBlogger_Ideas_Browser { private const PER_PAGE = 30; - /** - * Register the Ideas submenu page under PRAutoBlogger. - * - * @return void - */ + /** Transient prefix for per-idea generation status. */ + private const STATUS_PREFIX = 'prab_idea_gen_'; + + /** How long to keep per-idea status (seconds). */ + private const STATUS_TTL = 600; + + /** Register the Ideas submenu page under PRAutoBlogger. */ public function on_register_menu(): void { add_submenu_page( 'prautoblogger-settings', @@ -37,14 +32,7 @@ class PRAutoBlogger_Ideas_Browser { ); } - /** - * Render the ideas browser page. - * - * Reads filter/pagination parameters from $_GET, queries the analysis_results - * table, and includes the template. - * - * @return void - */ + /** Render the ideas browser page. */ public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; @@ -52,9 +40,8 @@ class PRAutoBlogger_Ideas_Browser { $paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1; $type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : ''; - $source_filter = isset( $_GET['source'] ) ? sanitize_key( $_GET['source'] ) : ''; - $result = $this->query_ideas( $paged, $type_filter, $source_filter ); + $result = $this->query_ideas( $paged, $type_filter ); $rows = $result['rows']; $total = $result['total']; $total_pages = (int) ceil( $total / self::PER_PAGE ); @@ -64,16 +51,202 @@ class PRAutoBlogger_Ideas_Browser { } /** - * Query analysis results with optional filtering and pagination. + * AJAX: trigger article generation from a specific idea. * - * Side effects: database SELECT. + * Loads the analysis result, stores it as a transient, sets a per-idea + * status transient, and schedules a one-shot cron event so the actual + * generation runs in a separate PHP process (Hostinger 120s limit). * - * @param int $paged Page number (1-based). - * @param string $type Filter by analysis_type (empty = all). - * @param string $source Filter: 'llm_research' or 'reddit' based on source_ids content. - * @return array{rows: array[], total: int} + * Side effects: transient writes, cron scheduling. */ - private function query_ideas( int $paged, string $type, string $source ): array { + public function on_ajax_generate_from_idea(): void { + check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => 'Insufficient permissions.' ], 403 ); + return; + } + + $idea_id = absint( $_POST['idea_id'] ?? 0 ); + if ( $idea_id < 1 ) { + wp_send_json_error( [ 'message' => 'Invalid idea ID.' ] ); + return; + } + + // Build Article_Idea from the analysis row. + $idea_data = self::load_idea_data( $idea_id ); + if ( null === $idea_data ) { + wp_send_json_error( [ 'message' => 'Idea not found.' ] ); + return; + } + + // Store idea for the cron handler and set initial "running" status. + set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL ); + set_transient( self::STATUS_PREFIX . $idea_id, [ + 'status' => 'running', + 'stage' => __( 'Starting generation…', 'prautoblogger' ), + 'started' => time(), + ], self::STATUS_TTL ); + + // Schedule background cron — passes idea_id as argument. + $hook = 'prautoblogger_generate_from_idea'; + if ( ! wp_next_scheduled( $hook, [ $idea_id ] ) ) { + wp_schedule_single_event( time(), $hook, [ $idea_id ] ); + } + spawn_cron(); + wp_remote_post( + site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ), + [ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ] + ); + + wp_send_json_success( [ 'message' => 'Generation started.' ] ); + } + + /** + * AJAX: return per-idea generation status for frontend polling. + * + * Side effects: none (reads transient only). + */ + public function on_ajax_idea_gen_status(): void { + check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => 'Insufficient permissions.' ], 403 ); + return; + } + + $idea_id = absint( $_POST['idea_id'] ?? 0 ); + $status = get_transient( self::STATUS_PREFIX . $idea_id ); + if ( ! is_array( $status ) ) { + wp_send_json_success( [ 'status' => 'idle' ] ); + return; + } + + wp_send_json_success( $status ); + } + + /** + * Cron handler: generate a single article from a stored idea. + * + * Runs in a background PHP process. Acquires the generation lock, + * runs Article_Worker, and writes the result to the per-idea status transient. + * + * Side effects: LLM API calls, DB writes, post creation, cost logging. + * + * @param int $idea_id Analysis result row ID. + */ + public static function on_cron_generate_from_idea( int $idea_id ): void { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @ignore_user_abort( true ); + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @set_time_limit( 300 ); + + $key = self::STATUS_PREFIX . $idea_id; + $idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id ); + if ( ! is_array( $idea_data ) ) { + set_transient( $key, [ 'status' => 'error', 'message' => 'Idea data expired.' ], self::STATUS_TTL ); + return; + } + + if ( ! PRAutoBlogger_Generation_Lock::acquire() ) { + set_transient( $key, [ 'status' => 'error', 'message' => 'Another generation is running.' ], self::STATUS_TTL ); + return; + } + + try { + $idea = new PRAutoBlogger_Article_Idea( $idea_data ); + $cost_tracker = new PRAutoBlogger_Cost_Tracker(); + $cost_tracker->set_run_id( wp_generate_uuid4() ); + + self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) ); + $worker = new PRAutoBlogger_Article_Worker( $cost_tracker ); + $result = $worker->generate( $idea ); + + // Amortize any research costs for this single-article run. + PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() ); + + // Find the generated post ID for the "View" link. + $post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() ); + + set_transient( $key, [ + 'status' => 'complete', + 'generated' => $result['generated'], + 'published' => $result['published'], + 'cost' => $result['cost'], + 'post_id' => $post_id, + ], self::STATUS_TTL ); + } catch ( \Throwable $e ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Idea generation %s for #%d: %s', get_class( $e ), $idea_id, $e->getMessage() ), + 'pipeline' + ); + set_transient( $key, [ 'status' => 'error', 'message' => $e->getMessage() ], self::STATUS_TTL ); + } + + PRAutoBlogger_Generation_Lock::release(); + delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id ); + } + + // ── Private helpers ───────────────────────────────────────────────── + + /** Update the per-idea status transient with a stage message. */ + private static function update_idea_stage( int $idea_id, string $stage ): void { + $key = self::STATUS_PREFIX . $idea_id; + $current = get_transient( $key ); + if ( is_array( $current ) ) { + $current['stage'] = $stage; + set_transient( $key, $current, self::STATUS_TTL ); + } + } + + /** Find the most recent post created by a specific run_id. */ + private static function find_post_by_run_id( string $run_id ): ?int { + global $wpdb; + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1", + $run_id + ) + ); + return null !== $post_id ? (int) $post_id : null; + } + + /** + * Load an analysis result row and map it to Article_Idea constructor data. + * + * @param int $idea_id Analysis result row ID. + * @return array|null Article_Idea-compatible array, or null. + */ + private static function load_idea_data( int $idea_id ): ?array { + global $wpdb; + $table = $wpdb->prefix . 'prautoblogger_analysis_results'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ), + ARRAY_A + ); + if ( ! $row ) { + return null; + } + + $meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: []; + return [ + 'topic' => $row['topic'], + 'article_type' => $row['analysis_type'], + 'suggested_title' => $meta['suggested_title'] ?? $row['topic'], + 'summary' => $row['summary'] ?? '', + 'score' => (float) $row['relevance_score'], + 'analysis_id' => (int) $row['id'], + 'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?: [], + 'key_points' => $meta['key_points'] ?? [], + 'target_keywords' => $meta['target_keywords'] ?? [], + ]; + } + + /** Query analysis results with optional filtering and pagination. */ + private function query_ideas( int $paged, string $type ): array { global $wpdb; $table = $wpdb->prefix . 'prautoblogger_analysis_results'; $where = []; @@ -103,19 +276,10 @@ class PRAutoBlogger_Ideas_Browser { ? $wpdb->get_results( $full_sql, ARRAY_A ) : $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); - return [ - 'rows' => $rows ?: [], - 'total' => $total, - ]; + return [ 'rows' => $rows ?: [], 'total' => $total ]; } - /** - * Get counts per analysis_type for the filter badges. - * - * Side effects: database SELECT. - * - * @return array Map of analysis_type => count. - */ + /** Get counts per analysis_type for the filter badges. */ private function get_type_counts(): array { global $wpdb; $table = $wpdb->prefix . 'prautoblogger_analysis_results'; diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index cb641df..d70b42b 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -73,7 +73,8 @@ class PRAutoBlogger { $review_queue = new PRAutoBlogger_Review_Queue(); add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] ); - add_action( 'admin_menu', [ new PRAutoBlogger_Ideas_Browser(), 'on_register_menu' ] ); + $ideas_browser = new PRAutoBlogger_Ideas_Browser(); + add_action( 'admin_menu', [ $ideas_browser, 'on_register_menu' ] ); add_action( 'admin_menu', [ new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ] ); ( new PRAutoBlogger_Post_List_Columns() )->register(); @@ -107,6 +108,9 @@ class PRAutoBlogger { $registry = $this->executor->get_model_registry(); add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] ); + + // Single-idea generation from the Ideas browser page. + add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] ); } /** Register AJAX handlers for admin actions. */ @@ -125,6 +129,10 @@ class PRAutoBlogger { add_action( 'wp_ajax_prautoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] ); add_action( 'wp_ajax_prautoblogger_clear_logs', [ new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ] ); + + $ideas = new PRAutoBlogger_Ideas_Browser(); + add_action( 'wp_ajax_prautoblogger_generate_from_idea', [ $ideas, 'on_ajax_generate_from_idea' ] ); + add_action( 'wp_ajax_prautoblogger_idea_gen_status', [ $ideas, 'on_ajax_idea_gen_status' ] ); } /** diff --git a/prautoblogger.php b/prautoblogger.php index 0d82547..1899fa2 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -8,7 +8,7 @@ declare(strict_types=1); * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.5.3 + * Version: 0.6.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.5.3' ); +define( 'PRAUTOBLOGGER_VERSION', '0.6.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/templates/admin/ideas-browser.php b/templates/admin/ideas-browser.php index 498185d..c1da706 100644 --- a/templates/admin/ideas-browser.php +++ b/templates/admin/ideas-browser.php @@ -19,6 +19,10 @@ if ( ! defined( 'ABSPATH' ) ) { $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' ); ?> +

@@ -50,35 +54,41 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );

-

+

- - + + - - - + + + + +
- - + +
@@ -95,9 +105,7 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
- = 70 ? '#00a32a' : ( $score_pct >= 40 ? '#dba617' : '#d63638' ); - ?> + = 70 ? '#00a32a' : ( $score_pct >= 40 ? '#dba617' : '#d63638' ); ?>
@@ -130,6 +138,24 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
+ + + Edit + | View + + + + + + +