diff --git a/assets/js/admin.js b/assets/js/admin.js index 5ca8e42..a81a325 100755 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -75,6 +75,25 @@ $referer.val(refUrl.pathname + refUrl.search); } } + + // Resume polling if a generation is already running (e.g., user + // navigated away and came back, or opened a new tab mid-generation). + var $genBtn = $('#prautoblogger-generate-now'); + if ($genBtn.length && config.ajaxUrl) { + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { action: 'prautoblogger_generation_status', nonce: config.generateNonce }, + timeout: 10000, + success: function (response) { + if (response.success && response.data && response.data.status === 'running') { + setButtonLoading($genBtn, config.generatingText || 'Generating…'); + showProgress(response.data.stage || 'Generation in progress…'); + pollGenerationStatus($genBtn); + } + } + }); + } }); /* @@ -146,29 +165,76 @@ * Shows pipeline stages while the request is in-flight so the user * knows it hasn't hung. */ - var generateStages = [ - { text: 'Collecting sources from Reddit…', delay: 0 }, - { text: 'Analyzing topics and scoring…', delay: 8000 }, - { text: 'Selecting best topic…', delay: 16000 }, - { text: 'Generating article draft via AI…', delay: 24000 }, - { text: 'Running editorial pass…', delay: 50000 }, - { text: 'Saving and publishing…', delay: 75000 }, - { text: 'Almost done — finalizing…', delay: 100000 } - ]; - var stageTimers = []; + /* + * Pipeline stages are now reported by the server via the status polling + * endpoint. The old client-side fake timers have been removed. + */ + + /** Poll interval handle for generation status checks. */ + var statusPollTimer = null; + + /** + * Poll the generation status endpoint until the run finishes. + * Updates the progress UI and resolves the button state on completion. + * + * @param {jQuery} $btn The Generate Now button. + */ + function pollGenerationStatus($btn) { + statusPollTimer = setInterval(function () { + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'prautoblogger_generation_status', + nonce: config.generateNonce + }, + timeout: 15000, + success: function (response) { + if (!response.success) return; + var d = response.data; + + if (d.status === 'running') { + showProgress(d.stage || 'Generating…'); + return; // Keep polling. + } + + // Terminal state — stop polling. + clearInterval(statusPollTimer); + statusPollTimer = null; + hideProgress(); + resetButton($btn, config.generateText || 'Generate Now'); + + if (d.status === 'complete') { + showStatus( + 'Generation complete: ' + d.generated + ' generated, ' + + d.published + ' published, ' + d.rejected + ' rejected. ' + + 'Cost: $' + parseFloat(d.cost).toFixed(4), + 'success' + ); + } else if (d.status === 'error') { + showStatus( + 'Generation failed: ' + (d.message || 'Unknown error'), + 'error' + ); + } else { + // idle or unknown — generation may have finished before + // polling started. Reset the button cleanly. + hideProgress(); + } + }, + error: function () { + // Network blip — keep polling, don't bail on transient errors. + } + }); + }, 5000); // Poll every 5 seconds. + } $(document).on('click', '#prautoblogger-generate-now', function () { var $btn = $(this); if ($btn.prop('disabled')) return; setButtonLoading($btn, config.generatingText || 'Generating…'); - - // Start pipeline stage messages. - stageTimers = []; - $.each(generateStages, function (i, stage) { - var t = setTimeout(function () { showProgress(stage.text); }, stage.delay); - stageTimers.push(t); - }); + showProgress('Starting generation…'); $.ajax({ url: config.ajaxUrl, @@ -178,21 +244,19 @@ nonce: config.generateNonce, force: '1' // Always clear stale locks on manual runs. }, - timeout: 300000, // 5 minutes — generation can be slow. + timeout: 30000, // 30s is plenty — this just schedules the cron. success: function (response) { if (response.success) { - var d = response.data; - showStatus( - 'Generation complete: ' + d.generated + ' generated, ' + - d.published + ' published, ' + d.rejected + ' rejected. ' + - 'Cost: $' + parseFloat(d.cost).toFixed(4), - 'success' - ); + // Generation kicked off in background — start polling. + showProgress(response.data.message || 'Generation started…'); + pollGenerationStatus($btn); } else { showStatus( 'Generation failed: ' + (response.data && response.data.message || 'Unknown error'), 'error' ); + hideProgress(); + resetButton($btn, config.generateText || 'Generate Now'); } }, error: function (xhr, status) { @@ -200,11 +264,6 @@ 'Request failed: ' + status + '. Check server error logs.', 'error' ); - }, - complete: function () { - // Clear all stage timers. - $.each(stageTimers, function (_, t) { clearTimeout(t); }); - stageTimers = []; hideProgress(); resetButton($btn, config.generateText || 'Generate Now'); } diff --git a/includes/class-executor.php b/includes/class-executor.php index b6ca443..b6c8cb7 100644 --- a/includes/class-executor.php +++ b/includes/class-executor.php @@ -59,11 +59,20 @@ class PRAutoBlogger_Executor { } } + /** Transient key for background generation status. */ + private const STATUS_TRANSIENT = 'prautoblogger_generation_status'; + + /** How long to keep the result available for polling (seconds). */ + private const STATUS_TTL = 600; + /** - * AJAX handler: trigger manual generation. - * Verifies nonce/capability, then runs the pipeline with the same DB lock. + * AJAX handler: kick off manual generation as a background cron job. * - * Side effects: API calls, database writes, WordPress post creation. + * Returns immediately so the browser never hits Hostinger's 120-second + * connection timeout. The frontend polls on_ajax_generation_status() + * every few seconds to get progress and final results. + * + * Side effects: schedules a WP-Cron event, writes a status transient. */ public function on_ajax_generate_now(): void { check_ajax_referer( 'prautoblogger_generate_now', 'nonce' ); @@ -73,37 +82,160 @@ class PRAutoBlogger_Executor { return; } - // LLM calls take 30-60+ seconds; extend PHP limit (may be blocked by host). - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - @set_time_limit( 300 ); - $force = isset( $_POST['force'] ) && '1' === $_POST['force']; if ( $force ) { $this->release_generation_lock(); + delete_transient( self::STATUS_TRANSIENT ); } + // Check if a run is already in progress. + $current = get_transient( self::STATUS_TRANSIENT ); + if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) { + wp_send_json_success( [ + 'background' => true, + 'message' => __( 'Generation already in progress.', 'prautoblogger' ), + ] ); + return; + } + + // Write initial "running" status for the frontend to poll. + set_transient( self::STATUS_TRANSIENT, [ + 'status' => 'running', + 'stage' => __( 'Starting generation...', 'prautoblogger' ), + 'started' => time(), + ], self::STATUS_TTL ); + + // Schedule immediate one-shot cron event. WordPress will fire this + // on the next page load (or via wp-cron.php) in a separate PHP process + // that is not subject to the 120-second connection timeout. + if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) { + wp_schedule_single_event( time(), 'prautoblogger_manual_generation' ); + } + + // Spawn the cron immediately via a non-blocking loopback request + // so we don't depend on the next visitor to trigger it. + // Some hosts (including Hostinger) set DISABLE_WP_CRON = true and + // rely on an external cron runner. spawn_cron() respects that flag + // and may no-op, so we also fire a direct loopback as a fallback. + spawn_cron(); + + // Direct non-blocking loopback to wp-cron.php — ensures the event + // fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops. + 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( [ + 'background' => true, + 'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ), + ] ); + } + + /** + * Cron handler for manual (background) generation. + * + * Runs in a separate PHP process triggered by WP-Cron, free from + * the web server's connection timeout. Writes progress and final + * results to a transient that the frontend polls. + * + * Side effects: API calls, database writes, WordPress post creation, + * transient updates. + */ + public function on_manual_generation(): void { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @set_time_limit( 300 ); + if ( ! $this->acquire_generation_lock() ) { - wp_send_json_error( [ 'message' => __( 'A generation run is already in progress. Pass force=1 to clear the lock.', 'prautoblogger' ) ] ); + set_transient( self::STATUS_TRANSIENT, [ + 'status' => 'error', + 'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ), + ], self::STATUS_TTL ); return; } try { - // Manual runs skip dedup so "Generate Now" always produces content. + // Update stage for frontend polling. + $this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) ); + $results = ( new PRAutoBlogger_Pipeline_Runner() ) ->set_skip_dedup( true ) ->run(); - wp_send_json_success( $results ); + + set_transient( self::STATUS_TRANSIENT, [ + 'status' => 'complete', + 'generated' => $results['generated'], + 'published' => $results['published'], + 'rejected' => $results['rejected'], + 'cost' => $results['cost'], + ], self::STATUS_TTL ); } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ), 'pipeline' ); - wp_send_json_error( [ 'message' => $e->getMessage() ] ); + set_transient( self::STATUS_TRANSIENT, [ + 'status' => 'error', + 'message' => $e->getMessage(), + ], self::STATUS_TTL ); } finally { $this->release_generation_lock(); } } + /** + * AJAX handler: return current generation status for frontend polling. + * + * Lightweight endpoint — reads a transient and returns it. Called every + * few seconds by admin.js while generation is in progress. + * + * Side effects: none. + */ + public function on_ajax_generation_status(): void { + check_ajax_referer( 'prautoblogger_generate_now', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 ); + return; + } + + $status = get_transient( self::STATUS_TRANSIENT ); + + if ( ! is_array( $status ) ) { + wp_send_json_success( [ 'status' => 'idle' ] ); + return; + } + + // If still "running" but started more than 10 minutes ago, assume it died. + if ( 'running' === $status['status'] && isset( $status['started'] ) && ( time() - $status['started'] ) > self::STATUS_TTL ) { + delete_transient( self::STATUS_TRANSIENT ); + wp_send_json_success( [ + 'status' => 'error', + 'message' => __( 'Generation timed out. Check the Activity Log for details.', 'prautoblogger' ), + ] ); + return; + } + + wp_send_json_success( $status ); + } + + /** + * Helper: update the generation stage for frontend polling. + * + * @param string $stage Human-readable stage description. + */ + private function update_generation_stage( string $stage ): void { + $current = get_transient( self::STATUS_TRANSIENT ); + if ( is_array( $current ) ) { + $current['stage'] = $stage; + set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL ); + } + } + /** * AJAX handler: generate images for existing posts that lack them. * diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index e6e8dd5..015e4cc 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -85,6 +85,10 @@ class PRAutoBlogger { add_action( 'prautoblogger_daily_generation', [ $this->executor, 'on_daily_generation' ] ); add_action( 'prautoblogger_collect_metrics', [ $this->executor, 'on_collect_metrics' ] ); + // Manual "Generate Now" runs as a one-shot cron event to avoid + // Hostinger's 120-second web-server connection timeout. + add_action( 'prautoblogger_manual_generation', [ $this->executor, 'on_manual_generation' ] ); + $registry = $this->executor->get_model_registry(); add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] ); } @@ -92,6 +96,7 @@ class PRAutoBlogger { /** Register AJAX handlers for admin actions. */ private function register_ajax_hooks(): void { add_action( 'wp_ajax_prautoblogger_generate_now', [ $this->executor, 'on_ajax_generate_now' ] ); + add_action( 'wp_ajax_prautoblogger_generation_status', [ $this->executor, 'on_ajax_generation_status' ] ); add_action( 'wp_ajax_prautoblogger_generate_image', [ $this->executor, 'on_ajax_generate_image' ] ); add_action( 'wp_ajax_prautoblogger_test_connection', [ $this->executor, 'on_ajax_test_connection' ] ); add_action( 'wp_ajax_prautoblogger_get_models', [ $this->executor, 'on_ajax_get_models' ] ); diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php index 39256e4..34447f6 100755 --- a/includes/core/class-pipeline-runner.php +++ b/includes/core/class-pipeline-runner.php @@ -61,17 +61,20 @@ class PRAutoBlogger_Pipeline_Runner { $target_count = absint( get_option( 'prautoblogger_daily_article_target', 1 ) ); // Step 1: Collect source data. + $this->broadcast_stage( __( 'Collecting sources from Reddit…', 'prautoblogger' ) ); $collector = new PRAutoBlogger_Source_Collector(); $collector->collect_from_all_sources(); // Step 2: Analyze collected data for patterns. // Pass a generous target so the LLM returns enough diverse ideas for // scoring to pick from — ask for 2× the daily target so dedup has room. + $this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) ); $llm = new PRAutoBlogger_OpenRouter_Provider(); $analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker ); $analysis = $analyzer->analyze_recent_data( max( $target_count * 2, 6 ) ); // Step 3: Score and deduplicate ideas. + $this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) ); $scorer = new PRAutoBlogger_Idea_Scorer(); $scorer->set_skip_dedup( $this->skip_dedup ); $ideas = $scorer->score_and_rank( $analysis, $target_count ); @@ -99,9 +102,11 @@ class PRAutoBlogger_Pipeline_Runner { } try { + $this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) ); $content = $generator->generate( $idea ); $generated++; + $this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) ); $review = $editor->review( $content, $idea ); if ( 'approved' === $review->get_verdict() || 'revised' === $review->get_verdict() ) { @@ -112,6 +117,7 @@ class PRAutoBlogger_Pipeline_Runner { ? ( $review->get_revised_content() ?? $content ) : $content; + $this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) ); if ( $auto_publish ) { $publisher->publish( $final_content, $idea, $review, $cost_tracker->get_run_id() ); $published++; @@ -144,4 +150,22 @@ class PRAutoBlogger_Pipeline_Runner { 'cost' => $total_cost, ]; } + + /** + * Update the generation status transient with the current pipeline stage. + * + * Called at key checkpoints during the pipeline so the frontend status + * poller can show real progress instead of fake timers. No-ops if the + * transient doesn't exist (e.g., during daily cron runs). + * + * @param string $stage Human-readable stage description. + */ + private function broadcast_stage( string $stage ): void { + $transient_key = 'prautoblogger_generation_status'; + $current = get_transient( $transient_key ); + if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) { + $current['stage'] = $stage; + set_transient( $transient_key, $current, 600 ); + } + } } diff --git a/prautoblogger.php b/prautoblogger.php index f4191d5..217b3e7 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.2.9 + * Version: 0.3.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.2.9' ); +define( 'PRAUTOBLOGGER_VERSION', '0.3.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );