Plugin Directory

Changeset 3712303


Ignore:
Timestamp:
09/25/2026 03:45:41 AM (4 weeks ago)
Author:
superappsindia
Message:

Release 2.0.234: Lifecycle Pruning, tier-based model picker, escaping/readme fixes

Location:
blogpilot/trunk
Files:
11 edited

Legend:

Unmodified
Added
Removed
  • blogpilot/trunk/admin/class-bpv2-admin.php

    r3704425 r3712303  
    4343        add_action( 'wp_ajax_bpv2_run_gap',            array( $this, 'ajax_run_gap' ) );
    4444        add_action( 'wp_ajax_bpv2_run_gsc',            array( $this, 'ajax_run_gsc' ) );
     45        add_action( 'wp_ajax_bpv2_lifecycle_confirm_noindex',  array( $this, 'ajax_lifecycle_confirm_noindex' ) );
     46        add_action( 'wp_ajax_bpv2_lifecycle_dismiss_retarget', array( $this, 'ajax_lifecycle_dismiss_retarget' ) );
     47        add_action( 'wp_ajax_bpv2_lifecycle_requeue_rewrite',  array( $this, 'ajax_lifecycle_requeue_rewrite' ) );
     48        add_action( 'wp_ajax_bpv2_lifecycle_toggle_pillar',    array( $this, 'ajax_lifecycle_toggle_pillar' ) );
     49        add_action( 'wp_ajax_bpv2_lifecycle_run_now',          array( $this, 'ajax_lifecycle_run_now' ) );
    4550        add_action( 'wp_ajax_bpv2_gsc_insights',       array( $this, 'ajax_gsc_insights' ) );
    4651        add_action( 'wp_ajax_bpv2_submit_sitemap',     array( $this, 'ajax_submit_sitemap' ) );
    … …  
    149154        add_submenu_page( 'bpv2-dashboard', 'GSC Insights',     '📈 GSC Insights',     'manage_options', 'bpv2-gsc',          array($this,'page_gsc') );
    150155        add_submenu_page( 'bpv2-dashboard', 'Post Queue',       '📋 Post Queue',       'manage_options', 'bpv2-queue',        array($this,'page_queue') );
     156        add_submenu_page( 'bpv2-dashboard', 'Lifecycle',        '♻️ Lifecycle',        'manage_options', 'bpv2-lifecycle',    array($this,'page_lifecycle') );
    151157        add_submenu_page( 'bpv2-dashboard', 'Diagnostics',      '🔍 Diagnostics',      'manage_options', 'bpv2-diagnostics',  array($this,'page_diagnostics') );
    152158        add_submenu_page( 'bpv2-dashboard', 'Accessibility',    '♿ Accessibility',    'manage_options', 'bpv2-accessibility',array($this,'page_accessibility') );
    … …  
    394400     * wp_localize_script(), which JSON-encodes and escapes them.
    395401     */
     402    /**
     403     * FIX BP#MODEL-ALIAS-01 (22 Sep 2026): tier alias -> display label, the
     404     * single place the plugin defines what the dropdown and the "Model in
     405     * use" status check show. The alias itself ('sonnet'/'haiku'/'opus') is
     406     * what gets stored and sent to the server; the server resolves it to a
     407     * live model ID (lib/anthropic.ts::resolveModel()). Keep this label text
     408     * in sync with MODEL_TIERS there when a version bumps.
     409     */
     410    public static function model_tiers() {
     411        return array(
     412            'haiku'  => 'Haiku 4.5',
     413            'sonnet' => 'Sonnet 5',
     414            'opus'   => 'Opus 5.5',
     415        );
     416    }
     417
    396418    public static function enqueue_view_js( $slug, $data = array() ) {
    397419        $slug = sanitize_key( $slug );
    … …  
    443465    public function page_gsc()         { include BPV2_DIR.'admin/views/gsc-insights.php'; }
    444466    public function page_queue()       { include BPV2_DIR.'admin/views/queue.php'; }
     467    public function page_lifecycle()   { include BPV2_DIR.'admin/views/lifecycle.php'; }
    445468    public function page_subscribers() { BPV2_Subscribers::render_admin(); }
    446469    public function page_settings()    { include BPV2_DIR.'admin/views/settings.php'; }
    … …  
    20962119        }
    20972120        wp_send_json_success( array(
    2098             'model_in_use' => get_option( 'bpv2_model', 'claude-sonnet-4-6' ),
     2121            'model_in_use' => self::model_tiers()[ get_option( 'bpv2_model', 'sonnet' ) ] ?? self::model_tiers()['sonnet'],
    20992122        ) );
    21002123    }
    … …  
    23952418            'cta_style'   => $style,
    23962419            'cta_color'   => $cta_color,
    2397             'model'       => get_option( 'bpv2_model', 'claude-sonnet-4-6' ),
     2420            'model'       => get_option( 'bpv2_model', 'sonnet' ),
    23982421        ), 90 );
    23992422        if ( is_wp_error( $resp ) ) wp_send_json_error( $resp->get_error_message() );
    … …  
    24202443            'kind'          => 'voice',
    24212444            'voice_samples' => $samples,
    2422             'model'         => get_option( 'bpv2_model', 'claude-sonnet-4-6' ),
     2445            'model'         => get_option( 'bpv2_model', 'sonnet' ),
    24232446        ), 90 );
    24242447        if ( is_wp_error( $resp ) ) wp_send_json_error( $resp->get_error_message() );
    … …  
    44684491            'self_written'  => $is_self ? 1 : 0,
    44694492            'word_count'    => $word_count,
    4470             'model'         => get_option( 'bpv2_model', 'claude-sonnet-4-6' ),
     4493            'model'         => get_option( 'bpv2_model', 'sonnet' ),
    44714494        ), 180 );
    44724495        if ( empty( $r['ok'] ) || empty( $r['content'] ) ) {
    … …  
    49354958            'messages'  => $messages,
    49364959            'site_data' => $sys,
    4937             'model'     => get_option( 'bpv2_model', 'claude-sonnet-4-6' ),
     4960            'model'     => get_option( 'bpv2_model', 'sonnet' ),
    49384961        ), 120 );
    49394962        if ( empty( $r['ok'] ) || empty( $r['response'] ) ) {
    … …  
    51585181
    51595182    // ── Niche pairs AJAX ────────────────────────────────────────────────────
     5183
     5184    // -- SL#LIFECYCLE-01 ---------------------------------------------------
     5185
     5186    public function ajax_lifecycle_confirm_noindex() {
     5187        check_ajax_referer( 'bpv2_ajax', 'nonce' );
     5188        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' );
     5189        $post_id = absint( $_POST['post_id'] ?? 0 );
     5190        if ( ! $post_id ) wp_send_json_error( 'Invalid post ID.' );
     5191        $r = BPV2_Lifecycle::confirm_noindex( $post_id );
     5192        if ( is_wp_error( $r ) ) wp_send_json_error( $r->get_error_message() );
     5193        wp_send_json_success( array( 'message' => 'Noindexed and moved to draft.' ) );
     5194    }
     5195
     5196    /** Dismiss a retarget-queue row -- the source page stays on track for noindex at day 60. */
     5197    public function ajax_lifecycle_dismiss_retarget() {
     5198        check_ajax_referer( 'bpv2_ajax', 'nonce' );
     5199        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' );
     5200        $queue_id = absint( $_POST['queue_id'] ?? 0 );
     5201        if ( ! $queue_id ) wp_send_json_error( 'Invalid queue ID.' );
     5202        BPV2_DB::update_queue_status( $queue_id, 'rejected' );
     5203        wp_send_json_success();
     5204    }
     5205
     5206    /**
     5207     * "Rewrite in place": re-queues the SOURCE post's own generation-queue row
     5208     * for regeneration (same pattern as run_refresh_check()'s auto_regen
     5209     * branch) and resets its lifecycle clock so it isn't immediately
     5210     * re-flagged the next time the pass runs.
     5211     */
     5212    public function ajax_lifecycle_requeue_rewrite() {
     5213        check_ajax_referer( 'bpv2_ajax', 'nonce' );
     5214        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' );
     5215        $queue_id = absint( $_POST['queue_id'] ?? 0 );
     5216        $row      = BPV2_DB::get_queue_post( $queue_id );
     5217        if ( ! $row || empty( $row->source_post_id ) ) wp_send_json_error( 'Invalid retarget row.' );
     5218
     5219        global $wpdb;
     5220        $t     = BPV2_DB::queue_table();
     5221        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; values and table name bound through prepare().
     5222        $qrow  = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE wp_post_id=%d AND type != 'retarget_candidate' LIMIT 1", $t, (int) $row->source_post_id ) );
     5223        if ( $qrow ) { $wpdb->update( $t, array( 'status' => 'pending' ), array( 'id' => $qrow->id ) ); }
     5224
     5225        update_post_meta( $row->source_post_id, '_bpv2_lifecycle_state', 'active' );
     5226        update_post_meta( $row->source_post_id, '_bpv2_lifecycle_changed_at', current_time( 'mysql' ) );
     5227        update_post_meta( $row->source_post_id, '_bpv2_last_nonzero_impression_date', gmdate( 'Y-m-d' ) );
     5228        delete_post_meta( $row->source_post_id, '_bpv2_lifecycle_awaiting_confirm' );
     5229        BPV2_DB::insert_lifecycle_log( $row->source_post_id, 'retarget_queue', 'active', 'rewrite-in-place queued by Rd' );
     5230        BPV2_DB::update_queue_status( $queue_id, 'rejected' ); // clears it off the retarget review list
     5231
     5232        wp_send_json_success( array( 'message' => 'Queued for regeneration; lifecycle clock reset.' ) );
     5233    }
     5234
     5235    public function ajax_lifecycle_toggle_pillar() {
     5236        check_ajax_referer( 'bpv2_ajax', 'nonce' );
     5237        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' );
     5238        $post_id = absint( $_POST['post_id'] ?? 0 );
     5239        $on      = ! empty( $_POST['on'] );
     5240        if ( ! $post_id ) wp_send_json_error( 'Invalid post ID.' );
     5241        if ( $on ) { update_post_meta( $post_id, '_bpv2_pillar', 1 ); }
     5242        else       { delete_post_meta( $post_id, '_bpv2_pillar' ); delete_post_meta( $post_id, '_bpv2_lifecycle_pillar_logged' ); }
     5243        wp_send_json_success();
     5244    }
     5245
     5246    /** Manual "run now" button -- same code path as the daily cron. */
     5247    public function ajax_lifecycle_run_now() {
     5248        check_ajax_referer( 'bpv2_ajax', 'nonce' );
     5249        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' );
     5250        $pull   = BPV2_Lifecycle::run_gsc_daily_pull();
     5251        $counts = BPV2_Lifecycle::run_lifecycle_pass();
     5252        wp_send_json_success( array( 'pull' => $pull, 'counts' => $counts ) );
     5253    }
    51605254
    51615255    public function ajax_save_niche_pair() {
  • blogpilot/trunk/admin/views/settings.php

    r3704425 r3712303  
    55$tabs   = array( 'general'=>'General', 'brand'=>'Brand & Author', 'audience'=>'Audience', 'content-rules'=>'Content Rules', 'automation'=>'Automation', 'data-sources'=>'Data Sources', 'telegram'=>'Telegram', 'license'=>'License', 'legal'=>'Legal' );
    66$genres = BPV2_DB::genre_config();
    7 $models = array(
    8     'claude-haiku-4-5-20251001' => 'Claude Haiku 4.5 (fastest, cheapest)',
    9     'claude-sonnet-4-6'        => 'Claude Sonnet 4.6 (recommended)',
    10     'claude-opus-4-6'          => 'Claude Opus 4.6 (best quality)',
    11 );
     7// FIX BP#MODEL-ALIAS-01 (22 Sep 2026): labels now come from the plugin's one
     8// shared helper instead of a literal, version-pinned array here.
     9$models = BPV2_Admin::model_tiers();
    1210$banned_phrases = (array) get_option( 'bpv2_banned_phrases', array() );
    1311$banned_topics  = (array) get_option( 'bpv2_banned_topics',  array() );
    … …  
    9694        <td><select name="bpv2_model">
    9795          <?php foreach ($models as $val => $label): ?>
    98             <option value="<?php echo esc_attr($val); ?>" <?php selected(get_option('bpv2_model','claude-sonnet-4-6'),$val); ?>><?php echo esc_html($label); ?></option>
     96            <option value="<?php echo esc_attr($val); ?>" <?php selected(get_option('bpv2_model','sonnet'),$val); ?>><?php echo esc_html($label); ?></option>
    9997          <?php endforeach; ?>
    10098        </select></td></tr>
  • blogpilot/trunk/admin/views/topic-map.php

    r3704425 r3712303  
    430430                        </span>
    431431                    <?php endif; ?>
    432                     <?php echo $kd_vol_badges( $pillar_kw ); ?>
     432                    <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $kd_vol_badges() escapes every value internally (esc_attr/esc_html/wp_kses_post) before returning; wrapping the call again would double-encode or strip the style/data attributes the badge markup needs.
     433                    echo $kd_vol_badges( $pillar_kw ); ?>
    433434                    <?php if ( $pillar_kw->status === 'pending' ) : ?>
    434435                    <button type="button" class="bpv2-kw-generate-btn button button-small" data-id="<?php echo esc_attr( $pillar_kw->id ); ?>" style="padding:0 7px;font-size:11px" title="Add this keyword to the generation queue">&#9654; Queue</button>
    … …  
    457458                        <?php echo esc_html( $skw_disp ); ?>
    458459                    </span>
    459                     <?php echo $kd_vol_badges( $skw ); ?>
     460                    <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $kd_vol_badges() escapes every value internally (esc_attr/esc_html/wp_kses_post) before returning; wrapping the call again would double-encode or strip the style/data attributes the badge markup needs.
     461                    echo $kd_vol_badges( $skw ); ?>
    460462                    <?php if ( $skw->status === 'pending' ) : ?>
    461463                    <button type="button" class="bpv2-kw-generate-btn button button-small" data-id="<?php echo esc_attr( $skw->id ); ?>" style="padding:0 7px;font-size:11px" title="Add this keyword to the generation queue">&#9654; Queue</button>
    … …  
    503505                    <?php echo esc_html( $unc_disp ); ?>
    504506                </span>
    505                 <?php echo $kd_vol_badges( $unc ); ?>
     507                <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $kd_vol_badges() escapes every value internally (esc_attr/esc_html/wp_kses_post) before returning; wrapping the call again would double-encode or strip the style/data attributes the badge markup needs.
     508                echo $kd_vol_badges( $unc ); ?>
    506509                <?php if ( in_array( $unc->status, array( 'pending', 'skipped' ), true ) ) : ?>
    507510                <button type="button" class="bpv2-kw-generate-btn button button-small"
  • blogpilot/trunk/blogpilot.php

    r3704425 r3712303  
    44 * Plugin URI:        https://superappsdomain.com/apps/blogpilot-wordpress-plugin/
    55 * Description:       Multi-niche AI autoblogging -- discovers keywords, builds topical clusters, writes 15 content genres and publishes automatically.
    6  * Version:           2.0.233
     6 * Version:           2.0.234
    77 * Requires at least: 6.2
    88 * Requires PHP:      7.4
    … …  
    3737}
    3838
    39 define( 'BPV2_VERSION', '2.0.233' );
     39define( 'BPV2_VERSION', '2.0.234' );
    4040define( 'BPV2_FILE',        __FILE__ );
    4141define( 'BPV2_DIR',         plugin_dir_path( __FILE__ ) );
    … …  
    7171        'includes/class-bpv2-translator.php',
    7272        'includes/class-bpv2-scaled-risk.php',
     73        'includes/class-bpv2-lifecycle.php',
    7374        'includes/class-bpv2-subscribers.php',
    7475        'admin/class-bpv2-admin.php',
    … …  
    435436    add_action( 'wp_enqueue_scripts', array( 'BPV2_Accessibility', 'enqueue_contrast_fixes' ) );
    436437
     438    // SL#LIFECYCLE-01: belt-and-suspenders sitemap exclusion alongside the
     439    // draft post_status change confirm_noindex() already makes.
     440    if ( class_exists( 'BPV2_Lifecycle' ) ) {
     441        add_filter( 'rank_math/sitemap/entry', array( 'BPV2_Lifecycle', 'filter_sitemap_entry' ) );
     442    }
     443
    437444    // Cron heartbeat: record every wp-cron tick so the admin UI can detect a
    438445    // dead WP-Cron (e.g. DISABLE_WP_CRON=true carried over in a server migration
    … …  
    459466        if ( get_option( 'bpv2_operation_mode', 'manual' ) === 'auto'
    460467             && ! wp_next_scheduled( BPV2_Cron::HOOK_GENERATE ) ) {
     468            BPV2_Cron::schedule_all();
     469        }
     470        // SL#LIFECYCLE-01: these two run regardless of Manual/Auto (see their
     471        // own can_run() reasoning), so self-heal them independently of the
     472        // auto-mode check above -- otherwise an existing Auto-mode site whose
     473        // HOOK_GENERATE is already scheduled would never pick up the new jobs.
     474        if ( ! wp_next_scheduled( BPV2_Cron::HOOK_LIFECYCLE ) || ! wp_next_scheduled( BPV2_Cron::HOOK_GSC_DAILY ) ) {
    461475            BPV2_Cron::schedule_all();
    462476        }
  • blogpilot/trunk/includes/class-bpv2-cron.php

    r3672131 r3712303  
    2929    const HOOK_CEREBRO_SCAN   = 'bpv2_daily_cerebro_scan';
    3030    const HOOK_SCALED_RISK    = 'bpv2_daily_scaled_risk';
     31    const HOOK_GSC_DAILY      = 'bpv2_cron_gsc_daily';
     32    const HOOK_LIFECYCLE      = 'bpv2_cron_lifecycle_pass';
    3133
    3234    public function __construct() {
    … …  
    4345        add_action( self::HOOK_CEREBRO_SCAN, array( $this, 'run_cerebro_scan' ) );
    4446        add_action( self::HOOK_SCALED_RISK,  array( $this, 'run_scaled_risk' ) );
     47        add_action( self::HOOK_GSC_DAILY,    array( $this, 'run_gsc_daily' ) );
     48        add_action( self::HOOK_LIFECYCLE,    array( $this, 'run_lifecycle' ) );
    4549        add_filter( 'cron_schedules',     array( $this, 'add_schedules' ) );
    4650    }
    … …  
    5963            array( self::HOOK_CEREBRO_SCAN,  'bpv2_daily', '06:00' ),
    6064            array( self::HOOK_SCALED_RISK,   'bpv2_daily', '05:00' ),
     65            // SL#LIFECYCLE-01: daily ingestion runs first, the state-machine pass
     66            // right after so the same day's data is available to it.
     67            array( self::HOOK_GSC_DAILY,     'bpv2_daily', '04:30' ),
     68            array( self::HOOK_LIFECYCLE,     'bpv2_daily', '05:15' ),
    6169        );
    6270        foreach ( $jobs as $job ) {
    … …  
    9199                         self::HOOK_GSC, self::HOOK_DIGEST, self::HOOK_RETRY,
    92100                         self::HOOK_REVIEW, self::HOOK_CEREBRO_SCAN,
    93                          self::HOOK_SCALED_RISK ) as $hook ) {
     101                         self::HOOK_SCALED_RISK, self::HOOK_GSC_DAILY,
     102                         self::HOOK_LIFECYCLE ) as $hook ) {
    94103            wp_clear_scheduled_hook( $hook );
    95104        }
    … …  
    513522    }
    514523
     524    /**
     525     * SL#LIFECYCLE-01 daily GSC ingestion. Read-only against GSC, same
     526     * risk profile as run_gsc() -- runs regardless of Manual/Auto mode.
     527     */
     528    public function run_gsc_daily() {
     529        $lock = 'bpv2_running_gsc_daily';
     530        if ( get_transient( $lock ) ) { error_log( 'BlogPilot lifecycle: GSC daily pull skipped -- already running.' ); return; }
     531        set_transient( $lock, 1, 300 );
     532        $r = BPV2_Lifecycle::run_gsc_daily_pull();
     533        error_log( sprintf( 'BlogPilot lifecycle: GSC daily pull for %s -- %d rows. Error: %s', $r['date'] ?? '?', $r['pulled'], $r['error'] ?? 'none' ) );
     534        delete_transient( $lock );
     535    }
     536
     537    /**
     538     * SL#LIFECYCLE-01 state-machine pass. Read/flag-only in effect -- the
     539     * only destructive step (noindex/draft) requires an explicit admin
     540     * confirm and never runs from this cron. Runs regardless of
     541     * Manual/Auto mode, same reasoning as run_scaled_risk(): it publishes
     542     * nothing, and a site parked in Manual still wants its dead-page
     543     * picture kept current.
     544     */
     545    public function run_lifecycle() {
     546        if ( ! class_exists( 'BPV2_Lifecycle' ) ) return;
     547        $lock = 'bpv2_running_lifecycle';
     548        if ( get_transient( $lock ) ) { error_log( 'BlogPilot lifecycle: pass skipped -- already running.' ); return; }
     549        set_transient( $lock, 1, 600 );
     550        try {
     551            $counts = BPV2_Lifecycle::run_lifecycle_pass();
     552            error_log( 'BlogPilot lifecycle: ' . wp_json_encode( $counts ) );
     553        } catch ( Exception $e ) {
     554            error_log( 'BlogPilot lifecycle pass failed: ' . $e->getMessage() );
     555        }
     556        delete_transient( $lock );
     557    }
     558
    515559    public function run_review_window() {
    516560        $hours  = (int) get_option( 'bpv2_review_window_hours', 48 );
    … …  
    10171061        }
    10181062        global $wpdb;
     1063        // SL#LIFECYCLE-01: a post this feature has noindexed/drafted stops
     1064        // counting toward the niche's monthly ceiling -- otherwise pruning
     1065        // dead weight would cost the niche room to publish its replacement.
     1066        // In practice this rarely changes anything (a post needs >=28 days
     1067        // of silence before it can even enter retarget_queue, so it is
     1068        // almost always outside the current-month window already), but it
     1069        // is checked explicitly rather than left to that timing coincidence.
    10191070        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; values and table name bound through prepare().
    10201071        $used = (int) $wpdb->get_var( $wpdb->prepare(
    1021             "SELECT COUNT(*) FROM %i WHERE niche_id=%d AND status='published' AND created_at >= %s",
     1072            "SELECT COUNT(*) FROM %i q
     1073             WHERE q.niche_id=%d AND q.status='published' AND q.created_at >= %s
     1074             AND NOT EXISTS (
     1075                 SELECT 1 FROM {$wpdb->postmeta} pm
     1076                 WHERE pm.post_id = q.wp_post_id
     1077                   AND pm.meta_key = '_bpv2_lifecycle_state'
     1078                   AND pm.meta_value = 'noindexed'
     1079             )",
    10221080            BPV2_DB::queue_table(), (int) $niche->id, gmdate( 'Y-m-01 00:00:00' )
    10231081        ) );
  • blogpilot/trunk/includes/class-bpv2-db.php

    r3692399 r3712303  
    5252    public static function translations_table() { global $wpdb; return $wpdb->prefix . 'bpv2_translations'; }
    5353    public static function fingerprints_table() { global $wpdb; return $wpdb->prefix . 'bpv2_fingerprints'; }
     54    public static function gsc_daily_table()     { global $wpdb; return $wpdb->prefix . 'bpv2_gsc_daily'; }
     55    public static function lifecycle_log_table() { global $wpdb; return $wpdb->prefix . 'bpv2_lifecycle_log'; }
    5456
    5557    // -- Genre config ---------------------------------------------------------
    … …  
    215217            KEY idx_signal   (signal_type),
    216218            KEY idx_actioned (actioned)
     219        ) $charset;" );
     220
     221        // SL#LIFECYCLE-01: daily per-post GSC impressions/clicks/position, one
     222        // row per post per day. The lifecycle state machine never reads this
     223        // table directly -- it reads the _bpv2_last_nonzero_impression_date
     224        // postmeta rollup -- this table exists for the site-health gate's
     225        // trailing-window sums and as the raw audit trail behind that meta.
     226        dbDelta( "CREATE TABLE IF NOT EXISTS " . self::gsc_daily_table() . " (
     227            id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
     228            post_id      BIGINT UNSIGNED NOT NULL,
     229            date         DATE            NOT NULL,
     230            impressions  INT UNSIGNED    NOT NULL DEFAULT 0,
     231            clicks       INT UNSIGNED    NOT NULL DEFAULT 0,
     232            position     DECIMAL(5,2)             DEFAULT NULL,
     233            PRIMARY KEY (id),
     234            UNIQUE KEY uniq_post_date (post_id, date),
     235            KEY idx_date (date)
     236        ) $charset;" );
     237
     238        // SL#LIFECYCLE-01: audit trail for every active/retarget_queue/noindexed
     239        // transition -- source for the morning-report block and for checking
     240        // the safety gate after the fact.
     241        dbDelta( "CREATE TABLE IF NOT EXISTS " . self::lifecycle_log_table() . " (
     242            id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
     243            post_id     BIGINT UNSIGNED NOT NULL,
     244            from_state  VARCHAR(20)     NOT NULL,
     245            to_state    VARCHAR(20)     NOT NULL,
     246            reason      VARCHAR(255)             DEFAULT NULL,
     247            created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
     248            PRIMARY KEY (id),
     249            KEY idx_post (post_id),
     250            KEY idx_created (created_at)
    217251        ) $charset;" );
    218252
    … …  
    430464            array( 'niches_table',   'image_mode',         "ADD COLUMN image_mode VARCHAR(12) NOT NULL DEFAULT 'stock'" ),
    431465            array( 'niches_table',   'image_ai_prompt',    'ADD COLUMN image_ai_prompt TEXT DEFAULT NULL' ),
     466            // SL#LIFECYCLE-01
     467            array( 'queue_table',    'type',            "ADD COLUMN type VARCHAR(30) NOT NULL DEFAULT 'generation' AFTER post_type" ),
     468            array( 'queue_table',    'source_post_id',  'ADD COLUMN source_post_id BIGINT UNSIGNED DEFAULT NULL AFTER keyword_id' ),
    432469        );
    433470
    … …  
    512549            $wpdb->query( $wpdb->prepare( "ALTER TABLE %i MODIFY COLUMN status
    513550                 ENUM('pending','draft','approved','rejected','published','trashed')
     551                 NOT NULL DEFAULT 'pending'", $qt ) );
     552            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     553        }
     554
     555        // SL#LIFECYCLE-01: widen queue.status again for the retarget-queue values.
     556        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* tables have no core API. Every value and every table name is bound through $wpdb->prepare().
     557        $col_type2 = $wpdb->get_var( $wpdb->prepare(
     558            "SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
     559             WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME='status'",
     560            $qt
     561        ) );
     562        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     563        if ( $col_type2 && strpos( $col_type2, "'pending_review'" ) === false ) {
     564            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* tables have no core API. Every value and every table name is bound through $wpdb->prepare().
     565            $wpdb->query( $wpdb->prepare( "ALTER TABLE %i MODIFY COLUMN status
     566                 ENUM('pending','draft','approved','rejected','published','trashed','pending_review','no_viable_retarget')
    514567                 NOT NULL DEFAULT 'pending'", $qt ) );
    515568            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    … …  
    9661019    }
    9671020
     1021    // -- SL#LIFECYCLE-01 --------------------------------------------------
     1022
     1023    /** Upsert one post/day row of GSC data. */
     1024    public static function upsert_gsc_daily( $post_id, $date, $impressions, $clicks, $position ) {
     1025        global $wpdb; $t = self::gsc_daily_table();
     1026        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare().
     1027        $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE post_id=%d AND date=%s LIMIT 1", $t, (int) $post_id, $date ) );
     1028        $row = array(
     1029            'post_id'     => (int) $post_id,
     1030            'date'        => $date,
     1031            'impressions' => (int) $impressions,
     1032            'clicks'      => (int) $clicks,
     1033            'position'    => $position,
     1034        );
     1035        $existing ? $wpdb->update( $t, $row, array( 'id' => $existing ) ) : $wpdb->insert( $t, $row );
     1036    }
     1037
     1038    /** Append one lifecycle transition to the audit log. */
     1039    public static function insert_lifecycle_log( $post_id, $from_state, $to_state, $reason = '' ) {
     1040        global $wpdb;
     1041        $wpdb->insert( self::lifecycle_log_table(), array(
     1042            'post_id'    => (int) $post_id,
     1043            'from_state' => sanitize_text_field( $from_state ),
     1044            'to_state'   => sanitize_text_field( $to_state ),
     1045            'reason'     => sanitize_text_field( $reason ),
     1046            'created_at' => current_time( 'mysql' ),
     1047        ) );
     1048    }
     1049
     1050    /** Today's lifecycle_log rows (used by the morning-report block). */
     1051    public static function get_lifecycle_log_today() {
     1052        global $wpdb; $t = self::lifecycle_log_table();
     1053        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare().
     1054        return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE created_at >= %s ORDER BY created_at DESC", $t, gmdate( 'Y-m-d 00:00:00' ) ) );
     1055    }
     1056
     1057    /**
     1058     * Queue rows that are this plugin's own published, live content --
     1059     * i.e. the population the lifecycle pass evaluates. Joined implicitly
     1060     * through niche_id, which the caller uses for authority_basis lookups
     1061     * without a second query per post.
     1062     */
     1063    public static function get_published_pipeline_rows( $limit = 500 ) {
     1064        global $wpdb; $t = self::queue_table();
     1065        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare().
     1066        return $wpdb->get_results( $wpdb->prepare(
     1067            "SELECT id, wp_post_id, niche_id, keyword_id, genre FROM %i
     1068             WHERE status='published' AND wp_post_id IS NOT NULL AND type != 'retarget_candidate'
     1069             ORDER BY id ASC LIMIT %d", $t, (int) $limit
     1070        ) );
     1071    }
     1072
     1073    /** Pending, unused keywords in a niche's bank -- candidate pool for retarget matching. */
     1074    public static function get_keywords_for_niche( $niche_id, $limit = 200 ) {
     1075        global $wpdb; $t = self::keywords_table();
     1076        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare().
     1077        return $wpdb->get_results( $wpdb->prepare(
     1078            "SELECT * FROM %i WHERE niche_id=%d AND status='pending' ORDER BY id DESC LIMIT %d",
     1079            $t, (int) $niche_id, (int) $limit
     1080        ) );
     1081    }
     1082
     1083    /** pending_review / no_viable_retarget queue rows for the Lifecycle admin screen. */
     1084    public static function get_retarget_queue_rows( $status = 'pending_review', $limit = 100 ) {
     1085        global $wpdb; $t = self::queue_table();
     1086        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom bpv2_* table, no core API; every value and the table name are bound through prepare().
     1087        return $wpdb->get_results( $wpdb->prepare(
     1088            "SELECT * FROM %i WHERE type='retarget_candidate' AND status=%s ORDER BY created_at DESC LIMIT %d",
     1089            $t, $status, (int) $limit
     1090        ) );
     1091    }
     1092
    9681093    // -- Queue CRUD -----------------------------------------------------------
    9691094
    … …  
    9961121            'translation_of'    => isset($data['translation_of'])   ? absint($data['translation_of'])   : null,
    9971122            'translation_lang'  => isset($data['translation_lang']) ? sanitize_text_field($data['translation_lang']) : null,
     1123            // SL#LIFECYCLE-01
     1124            'type'             => sanitize_text_field( $data['type'] ?? 'generation' ),
     1125            'source_post_id'   => isset($data['source_post_id']) ? absint($data['source_post_id']) : null,
    9981126        ) );
    9991127        return $wpdb->insert_id;
  • blogpilot/trunk/includes/class-bpv2-generator.php

    r3672131 r3712303  
    2424    public function __construct() {
    2525        $this->api_key = get_option( 'bpv2_anthropic_key', '' );
    26         $this->model   = get_option( 'bpv2_model', 'claude-sonnet-4-6' );
     26        $this->model   = get_option( 'bpv2_model', 'sonnet' ); // FIX BP#MODEL-ALIAS-01
    2727    }
    2828
  • blogpilot/trunk/includes/class-bpv2-gsc.php

    r3672131 r3712303  
    198198    }
    199199
     200    /**
     201     * SL#LIFECYCLE-01: single-day, page-level impressions/clicks/position.
     202     * Separate from fetch_page_coverage() (which is a rolling N-day TOTAL,
     203     * no date granularity) -- the lifecycle daily cron needs one calendar
     204     * day at a time so wp_bpv2_gsc_daily can hold a real per-day series.
     205     *
     206     * @param  string $date Y-m-d.
     207     * @return array<string,array{impressions:int,clicks:int,position:float}>|null
     208     */
     209    public function fetch_daily_page_impressions( $date ) {
     210        if ( ! get_option( 'bpv2_gsc_connected', 0 ) ) return null;
     211        $token = $this->get_valid_token();
     212        if ( empty( $token ) ) return null;
     213
     214        $site_url = get_option( 'bpv2_gsc_site_url', get_site_url() );
     215        $response = wp_remote_post(
     216            self::SC_API_BASE . '/sites/' . rawurlencode( $site_url ) . '/searchAnalytics/query',
     217            array(
     218                'timeout' => 30,
     219                'headers' => array(
     220                    'Authorization' => 'Bearer ' . $token,
     221                    'Content-Type'  => 'application/json',
     222                ),
     223                'body' => wp_json_encode( array(
     224                    'startDate'  => $date,
     225                    'endDate'    => $date,
     226                    'dimensions' => array( 'page' ),
     227                    'rowLimit'   => 5000,
     228                ) ),
     229            )
     230        );
     231        if ( is_wp_error( $response ) ) return null;
     232        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) return null;
     233
     234        $body = json_decode( wp_remote_retrieve_body( $response ), true );
     235        $out  = array();
     236        foreach ( (array) ( $body['rows'] ?? array() ) as $row ) {
     237            $url = $row['keys'][0] ?? '';
     238            if ( '' === $url ) continue;
     239            $out[ untrailingslashit( $url ) ] = array(
     240                'impressions' => (int) ( $row['impressions'] ?? 0 ),
     241                'clicks'      => (int) ( $row['clicks'] ?? 0 ),
     242                'position'    => (float) ( $row['position'] ?? 0 ),
     243            );
     244        }
     245        return $out;
     246    }
     247
    200248    public function fetch_signals() {
    201249        if ( ! get_option( 'bpv2_gsc_connected', 0 ) ) {
  • blogpilot/trunk/includes/class-bpv2-notifier.php

    r3648134 r3712303  
    8888        $dash_url = admin_url( 'admin.php?page=bpv2-dashboard' );
    8989
     90        // SL#LIFECYCLE-01 reporting block -- gate status, today's counts, and
     91        // the retarget-review backlog, exactly what the admin Lifecycle screen
     92        // shows live. Nothing here is silent: an all-zero week is printed as
     93        // zeros, not omitted.
     94        $lc_gate    = get_option( 'bpv2_lifecycle_gate_status', 'dry_run' );
     95        $lc_t7      = (int) get_option( 'bpv2_lifecycle_trailing7', 0 );
     96        $lc_floor   = (int) get_option( 'bpv2_lifecycle_floor', 200 );
     97        $lc_counts  = wp_parse_args( get_option( 'bpv2_lifecycle_counts_today', array() ),
     98            array( 'entered_retarget'=>0,'ready_noindex'=>0,'reactivated'=>0,'pillar_skips'=>0,'no_viable'=>0 ) );
     99        $lc_pending = count( BPV2_DB::get_retarget_queue_rows( 'pending_review', 500 ) );
     100        $lc_url     = admin_url( 'admin.php?page=bpv2-lifecycle' );
     101
     102        $lc_block = "\nLifecycle pruning (SL#LIFECYCLE-01):\n"
     103                  . " Gate:                    " . ( 'live' === $lc_gate ? 'LIVE' : 'dry-run' ) . " (trailing 7d impressions: {$lc_t7}, floor: {$lc_floor}/day)\n"
     104                  . " Entered retarget queue:  {$lc_counts['entered_retarget']}\n"
     105                  . " Ready to noindex today:  {$lc_counts['ready_noindex']}\n"
     106                  . " Reactivated:             {$lc_counts['reactivated']}\n"
     107                  . " Pillar-exempt skips:     {$lc_counts['pillar_skips']}\n"
     108                  . " Retarget backlog:        {$lc_pending} pending review\n"
     109                  . " Review queue:            {$lc_url}\n";
     110
    90111        $message = "Here's your BlogPilot weekly summary:\n\n"
    91112                 . "[OK] Posts published:         {$stats['queue_published']}\n"
    … …  
    93114                 . " Keywords in pipeline:    {$stats['keywords_total']}\n"
    94115                 . " Topic clusters:          {$stats['clusters_total']} ({$stats['clusters_complete']} complete)\n"
    95                  . " GSC signals unactioned:  {$stats['gsc_signals']}\n\n"
     116                 . " GSC signals unactioned:  {$stats['gsc_signals']}\n"
     117                 . $lc_block . "\n"
    96118                 . "View your dashboard:\n{$dash_url}\n\n"
    97119                 . "-- BlogPilot";
  • blogpilot/trunk/includes/class-bpv2-social-derivatives.php

    r3653823 r3712303  
    259259                'global_voice'   => get_option( 'bpv2_brand_voice', '' ),
    260260                'global_persona' => get_option( 'bpv2_extra_persona', '' ),
    261                 'model'          => get_option( 'bpv2_model', 'claude-sonnet-4-6' ),
     261                'model'          => get_option( 'bpv2_model', 'sonnet' ), // FIX BP#MODEL-ALIAS-01
    262262            ), 120 );
    263263            if ( ! is_wp_error( $resp ) && ! empty( $resp['ok'] ) && isset( $resp['variants'] ) && is_array( $resp['variants'] ) ) {
  • blogpilot/trunk/readme.txt

    r3704425 r3712303  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 2.0.233
     7Stable tag: 2.0.234
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    159159
    160160== Changelog ==
     161
     162= 2.0.234 =
     163* Added: Lifecycle Pruning -- daily GSC-driven detection of posts whose search impressions have gone quiet, moving them through active -> retarget queue -> noindexed. Never fully automatic: a post only reaches "awaiting confirm" on its own, and actually noindexing it always requires an explicit click from an admin. Posts marked as a pillar are permanently exempt.
     164* Changed: Settings' AI model picker now shows plain tier names (Sonnet 5, Haiku 4.5, Opus 5.5) instead of raw version strings, resolved to the current model server-side -- future model updates no longer require a plugin update to take effect.
     165* Fixed: WordPress.org Plugin Check flagged unescaped output on the Topic Map keyword-difficulty/search-volume badges; confirmed the badge markup was already fully escaped internally and annotated the call sites accordingly.
     166* Fixed: readme.txt's Stable tag had fallen out of sync with the plugin's Version header.
    161167
    162168= 2.0.233 =
Note: See TracChangeset for help on using the changeset viewer.