feat: M4 chained-cron checkpoint generation + board button (v0.21.0)
## Board 'New Article' button
- templates/admin/board-page.php: add #prab-board-generate-now button + status div
- includes/admin/class-board-page.php: inject generateNonce + i18n strings into prabBoard;
enqueue companion board-generate.js
- assets/js/board-generate.js: NEW – board button AJAX handler (fires
prautoblogger_generate_now, resets board backoff on success)
- assets/js/board.js: applySnapshot() re-enables button when no active runs
## Chained-cron checkpoint machine (retires SSH workaround)
- includes/core/class-generation-checkpoint-runner.php: NEW (295 lines)
Tick 1 (prautoblogger_gen_orchestrate): orchestrate_only() → persist queue → schedule Tick 2
Tick 2..N (prautoblogger_gen_tick): pop one idea → Article_Worker → reschedule or finalize
Budget/halt contract + lock release on all exit paths
- includes/class-executor.php: on_manual_generation() replaced with 1-line delegate to
Generation_Checkpoint_Runner::on_orchestrate_tick() (SSH workaround retired)
- includes/class-prautoblogger.php: register ORCHESTRATE_ACTION + GENERATE_ACTION hooks
- includes/core/class-pipeline-runner.php: expose orchestrate_only() public wrapper
- includes/core/class-pipeline-status.php: add write_initial() + write_error() helpers
## WP-CLI command
- includes/admin/class-wp-cli-commands.php: wp prautoblogger generate → kick_off()
## IA / perf pass (settings-backed pagination + DB indexes)
- includes/admin/class-board-data-provider.php: replace hardcoded posts_per_page=20 with
get_option('prautoblogger_board_column_limit', DEFAULT)
- includes/admin/class-board-gen-log-query.php: replace LIMIT 10/20 literals with settings-backed values
- includes/admin/class-ideas-browser-query.php: NEW – query helpers extracted from Ideas_Browser
(300-line rule split): query_ideas(), get_type_counts(), load_idea_data(),
find_post_by_run_id(), update_idea_stage()
- includes/admin/class-ideas-browser.php: delegates all query work to Ideas_Browser_Query
- includes/class-activator.php: set_default_options() adds board_column_limit + ideas_per_page
- includes/admin/class-admin-page.php: add new settings to numeric sanitization list
- prautoblogger.php: PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT=20, PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE=30
- includes/class-schema-installer.php: indexes on generation_log + analysis_results (db 1.4.0)
- includes/class-pipeline-schema-installer.php: KEY started_at on runs table
## Guard P2s
- scripts/check-constants.php: --self-test mode + inline CONSTANTS_GUARD_WINDOW comment
- .github/workflows/ci.yml: check-constants-self-test step after main constants check
## PHPUnit test
- tests/unit/Core/GenerationCheckpointRunnerTest.php: NEW (227 lines)
4 tests: kick_off schedules+transient, empty-queue finalizes, halted-run aborts+cleanup,
multi-idea pops-one-and-reschedules
## Docs + version
- prautoblogger.php: Version 0.21.0, PRAUTOBLOGGER_DB_VERSION 1.4.0
- ARCHITECTURE.md: §25 Chained-Cron Checkpoint Generation Path
- CHANGELOG.md: v0.21.0 entry
- INFORMATION-ARCHITECTURE.md: NEW – admin pages, entry points, DB tables, options, CLI
- uninstall.php: purge prautoblogger_gen_orchestrate + prautoblogger_gen_tick cron hooks
Agent-Session: phase2-admin-m4-20260614
This commit is contained in:
136
includes/admin/class-ideas-browser-query.php
Normal file
136
includes/admin/class-ideas-browser-query.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Query helpers for PRAutoBlogger_Ideas_Browser.
|
||||
*
|
||||
* Extracted to keep class-ideas-browser.php under 300 lines (CONVENTIONS §1).
|
||||
* All methods are static; class is never instantiated.
|
||||
*/
|
||||
class PRAutoBlogger_Ideas_Browser_Query {
|
||||
|
||||
/** Fall-through if setting is absent. */
|
||||
public const PER_PAGE_DEFAULT = 30;
|
||||
|
||||
/** Transient key prefix for per-idea generation status. */
|
||||
public const STATUS_PREFIX = 'prab_idea_gen_';
|
||||
|
||||
/** How long to keep per-idea status (seconds). */
|
||||
public const STATUS_TTL = 600;
|
||||
|
||||
/** Query analysis results with optional filtering and pagination. */
|
||||
public static function query_ideas( int $paged, string $type ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
$where = array();
|
||||
$params = array();
|
||||
|
||||
if ( '' !== $type ) {
|
||||
$where[] = 'analysis_type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
|
||||
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
|
||||
$per_page = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', self::PER_PAGE_DEFAULT ) );
|
||||
$offset = ( $paged - 1 ) * $per_page;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
);
|
||||
|
||||
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
|
||||
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', $per_page, $offset );
|
||||
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
return array(
|
||||
'rows' => $rows ?? array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get counts per analysis_type for the filter badges. */
|
||||
public static function get_type_counts(): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$counts = array();
|
||||
foreach ( $rows ?? array() as $row ) {
|
||||
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an analysis result row and map it to Article_Idea constructor data.
|
||||
*
|
||||
* @param int $idea_id Analysis result row ID.
|
||||
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
|
||||
*/
|
||||
public 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( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
if ( ! $row ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
|
||||
return array(
|
||||
'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 ) ?? array(),
|
||||
'key_points' => $meta['key_points'] ?? array(),
|
||||
'target_keywords' => $meta['target_keywords'] ?? array(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Find the most recent post created by a specific run_id. */
|
||||
public 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( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"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;
|
||||
}
|
||||
|
||||
/** Update the per-idea status transient with a stage message. */
|
||||
public 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user