## 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
237 lines
7.1 KiB
JavaScript
237 lines
7.1 KiB
JavaScript
/**
|
||
* PRAutoBlogger — Kanban Board Poller
|
||
*
|
||
* Live-polls the board status AJAX endpoint and updates the four columns
|
||
* (Generating | In Review | Published | Failed) in-place without a full
|
||
* page reload.
|
||
*
|
||
* Poll behaviour:
|
||
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
|
||
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
|
||
* at 4× the base interval — prevents needless AJAX on an idle board.
|
||
* - Resets to base interval as soon as an active run is detected again.
|
||
*
|
||
* Click-throughs (M1):
|
||
* - Generating → Activity Log
|
||
* - In Review → Review Queue
|
||
* - Published → Post edit screen
|
||
* - Failed → Activity Log
|
||
* (M2 rewires all to Article Dossier)
|
||
*
|
||
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
|
||
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
|
||
*
|
||
* @see includes/admin/class-board-page.php — AJAX handler + localise.
|
||
* @see assets/css/board.css — Card and column styles.
|
||
* @see templates/admin/board-page.php — Initial server-rendered HTML.
|
||
*/
|
||
( function ( $ ) {
|
||
'use strict';
|
||
|
||
var cfg = window.prabBoard || {};
|
||
var baseInterval = cfg.pollInterval || 5000;
|
||
var maxBackoff = 4;
|
||
var backoffFactor = 1;
|
||
var pollTimer = null;
|
||
var $board = null;
|
||
var $errorBanner = null;
|
||
|
||
/** Escape HTML for safe DOM insertion. */
|
||
function escHtml( str ) {
|
||
return $( '<div>' ).text( String( str ) ).html();
|
||
}
|
||
|
||
/** Format cost to 4 decimal places with a leading $. */
|
||
function formatCost( cost ) {
|
||
var n = parseFloat( cost ) || 0;
|
||
return '$' + n.toFixed( 4 );
|
||
}
|
||
|
||
/**
|
||
* Build a card HTML string from a card data object.
|
||
*
|
||
* @param {Object} card Card data from server.
|
||
* @param {string} columnKey Column identifier.
|
||
* @returns {string} HTML string.
|
||
*/
|
||
function buildCardHtml( card, columnKey ) {
|
||
var isGenerating = ( columnKey === 'generating' );
|
||
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
|
||
var html = '';
|
||
|
||
html += '<div class="' + escHtml( cls ) + '"';
|
||
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
|
||
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
|
||
|
||
if ( isGenerating ) {
|
||
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
|
||
}
|
||
|
||
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
|
||
|
||
// v0.20.0: human-modified runs are visually distinct at run-list level.
|
||
if ( card.human_modified ) {
|
||
html += '<span class="prab-card-human-chip">' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + '</span>';
|
||
}
|
||
|
||
if ( card.cost_total ) {
|
||
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
|
||
html += escHtml( formatCost( card.cost_total ) );
|
||
html += '</span></div>';
|
||
}
|
||
|
||
if ( isGenerating && card.stage_current ) {
|
||
html += '<div class="prab-card-stage">';
|
||
/* translators: stage name */
|
||
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
|
||
html += '</div>';
|
||
}
|
||
|
||
if ( card.error_message ) {
|
||
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
|
||
if ( card.error_message.split( ' ' ).length > 12 ) {
|
||
errShort += '…';
|
||
}
|
||
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
|
||
}
|
||
|
||
// Click-through link.
|
||
var linkUrl = '';
|
||
var linkText = '';
|
||
if ( card.click_action === 'dossier' && card.dossier_url ) {
|
||
linkUrl = card.dossier_url;
|
||
linkText = cfg.strings.view;
|
||
} else if ( card.click_action === 'review' && card.review_url ) {
|
||
linkUrl = card.review_url;
|
||
linkText = cfg.strings.view;
|
||
} else if ( card.click_action === 'edit' && card.edit_url ) {
|
||
linkUrl = card.edit_url;
|
||
linkText = cfg.strings.edit;
|
||
} else if ( card.click_action === 'logs' && card.log_url ) {
|
||
linkUrl = card.log_url;
|
||
linkText = cfg.strings.viewLog;
|
||
}
|
||
|
||
if ( linkUrl ) {
|
||
html += '<div class="prab-card-actions">';
|
||
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
|
||
html += escHtml( linkText );
|
||
html += '</a></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Re-render a single column from the server snapshot.
|
||
*
|
||
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
|
||
* to prevent flicker on the running spinner.
|
||
*
|
||
* @param {string} colKey Column identifier.
|
||
* @param {Array} cards Array of card objects.
|
||
* @returns {void}
|
||
*/
|
||
function renderColumn( colKey, cards ) {
|
||
var $col = $board.find( '[data-column="' + colKey + '"]' );
|
||
var $cards = $col.find( '[data-column-cards]' );
|
||
var $pill = $col.find( '.prab-col-count' );
|
||
|
||
$pill.text( cards.length );
|
||
|
||
if ( ! cards.length ) {
|
||
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
|
||
return;
|
||
}
|
||
|
||
// Build new card HTML.
|
||
var newHtml = '';
|
||
for ( var i = 0; i < cards.length; i++ ) {
|
||
newHtml += buildCardHtml( cards[ i ], colKey );
|
||
}
|
||
$cards.html( newHtml );
|
||
}
|
||
|
||
/**
|
||
* Apply a server snapshot to all four columns.
|
||
*
|
||
* @param {Object} snapshot Board snapshot from AJAX.
|
||
* @returns {void}
|
||
*/
|
||
function applySnapshot( snapshot ) {
|
||
renderColumn( 'generating', snapshot.generating || [] );
|
||
renderColumn( 'in_review', snapshot.in_review || [] );
|
||
renderColumn( 'published', snapshot.published || [] );
|
||
renderColumn( 'failed', snapshot.failed || [] );
|
||
// Re-enable board Generate button once no active runs remain.
|
||
if ( ! snapshot.has_active_runs ) {
|
||
$( '#prab-board-generate-now' ).prop( 'disabled', false )
|
||
.text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compute the next poll delay based on whether there are active runs.
|
||
*
|
||
* @param {boolean} hasActiveRuns
|
||
* @returns {number} Milliseconds until next poll.
|
||
*/
|
||
function nextDelay( hasActiveRuns ) {
|
||
if ( hasActiveRuns ) {
|
||
backoffFactor = 1;
|
||
return baseInterval;
|
||
}
|
||
backoffFactor = Math.min( backoffFactor + 1, maxBackoff );
|
||
return baseInterval * backoffFactor;
|
||
}
|
||
|
||
/**
|
||
* Schedule the next poll after a given delay.
|
||
*
|
||
* @param {number} delay Milliseconds.
|
||
* @returns {void}
|
||
*/
|
||
function schedulePoll( delay ) {
|
||
clearTimeout( pollTimer );
|
||
pollTimer = setTimeout( doPoll, delay );
|
||
}
|
||
|
||
/**
|
||
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
|
||
*
|
||
* @returns {void}
|
||
*/
|
||
function doPoll() {
|
||
$.ajax( {
|
||
url: cfg.ajaxUrl,
|
||
method: 'POST',
|
||
data: {
|
||
action: cfg.action,
|
||
nonce: cfg.nonce
|
||
},
|
||
success: function ( response ) {
|
||
if ( $errorBanner ) {
|
||
$errorBanner.removeClass( 'is-visible' );
|
||
}
|
||
if ( response && response.success && response.data ) {
|
||
applySnapshot( response.data );
|
||
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
|
||
} else {
|
||
schedulePoll( nextDelay( false ) );
|
||
}
|
||
},
|
||
error: function () {
|
||
if ( $errorBanner ) {
|
||
$errorBanner.addClass( 'is-visible' );
|
||
}
|
||
schedulePoll( nextDelay( false ) );
|
||
}
|
||
} );
|
||
}
|
||
|
||
|
||
} );
|
||
|
||
}( jQuery ) );
|