Files
prautoblogger/includes/ajax/class-pipeline-history-handler.php
peptiderepo 2569b08862 feat: Pipeline Settings M3 — assembled-instructions preview + prompt version history/diff (#173, v0.25.0)
Read-only assembled-instructions preview (last-run rendered prompt) with Template-vs-Preview guardrail; per-prompt version history + LCS diff. Stage-list-driven. QA APPROVE df92ed3; CI green.

Agent-Session: pipeline-settings-m3-merge
2026-06-23 13:33:18 +07:00

292 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX handler: fetch version history and compute diffs for a prompt key.
*
* What: Two actions on one class —
* prautoblogger_pipeline_history: returns all stored versions for a key
* (version number, author, created_at, active flag). Read-only; never
* mutates the registry.
* prautoblogger_pipeline_diff: returns a line-level unified diff between
* two specified versions (or between a version and the current active
* body). Diff is computed server-side (no external binary required)
* and returned as an array of {type, text} lines (added/removed/context)
* so the JS renderer can apply colour without parsing unified format.
*
* Security: manage_options cap + nonce on every request. All registry key
* inputs are validated against the Step_Map allowlist. Diff output
* is esc_html'd before encoding.
*
* Who triggers it: pipeline-settings.js on history accordion open and diff
* button click.
* Dependencies: PRAutoBlogger_Prompt_Registry_Writer (list_versions),
* PRAutoBlogger_Prompt_Registry (get_active, default_body),
* PRAutoBlogger_Pipeline_Settings_Step_Map (key allowlist).
*
* @see ajax/class-pipeline-preview-handler.php -- Preview assembled text.
* @see core/class-prompt-registry-writer.php -- list_versions() source.
* @see ARCHITECTURE.md #21 -- Prompt registry design.
*/
class PRAutoBlogger_Pipeline_History_Handler {
/** AJAX action: list all versions for a key. */
public const ACTION_HISTORY = 'prautoblogger_pipeline_history';
/** AJAX action: diff two versions. */
public const ACTION_DIFF = 'prautoblogger_pipeline_diff';
/**
* Register wp-ajax hooks.
*
* @return void
*/
public static function register_hooks(): void {
add_action( 'wp_ajax_' . self::ACTION_HISTORY, array( __CLASS__, 'handle_history' ) );
add_action( 'wp_ajax_' . self::ACTION_DIFF, array( __CLASS__, 'handle_diff' ) );
}
/**
* Return all stored versions for a prompt key, newest first.
*
* POST fields:
* nonce string wp_nonce for 'prautoblogger_pipeline_history'.
* prompt_key string Registry key slug (dots to hyphens).
*
* @return void
*/
public static function handle_history(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
}
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::ACTION_HISTORY ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
}
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
$prompt_key = self::resolve_key_from_slug( $slug );
if ( null === $prompt_key ) {
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
}
$rows = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $prompt_key );
$versions = array();
foreach ( $rows as $row ) {
$versions[] = array(
'version' => (int) $row['version'],
'author' => esc_html( (string) $row['author'] ),
'created_at' => esc_html( (string) $row['created_at'] ),
'active' => (bool) $row['active'],
);
}
wp_send_json_success( array( 'versions' => $versions ) );
}
/**
* Compute and return a line-level diff between two prompt versions.
*
* POST fields:
* nonce string wp_nonce for 'prautoblogger_pipeline_diff'.
* prompt_key string Registry key slug (dots to hyphens).
* version_a int Older version number (or 0 = factory default).
* version_b int Newer version number (or 0 = current active).
*
* Returns {lines: [{type: 'added'|'removed'|'context', text: string}], header: string}.
*
* @return void
*/
public static function handle_diff(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
}
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::ACTION_DIFF ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
}
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
$prompt_key = self::resolve_key_from_slug( $slug );
if ( null === $prompt_key ) {
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
}
$version_a = isset( $_POST['version_a'] ) ? (int) $_POST['version_a'] : 0;
$version_b = isset( $_POST['version_b'] ) ? (int) $_POST['version_b'] : 0;
$body_a = self::fetch_version_body( $prompt_key, $version_a );
$body_b = self::fetch_version_body( $prompt_key, $version_b );
if ( null === $body_a || null === $body_b ) {
wp_send_json_error( array( 'message' => __( 'Version not found.', 'prautoblogger' ) ), 404 );
}
$label_a = 0 === $version_a ? __( 'default', 'prautoblogger' ) : sprintf( 'v%d', $version_a );
$label_b = 0 === $version_b ? __( 'current', 'prautoblogger' ) : sprintf( 'v%d', $version_b );
$lines = self::compute_diff( $body_a, $body_b );
wp_send_json_success(
array(
'lines' => $lines,
'header' => esc_html(
sprintf(
/* translators: 1: prompt key, 2: old version label, 3: new version label */
__( '%1$s — %2$s → %3$s', 'prautoblogger' ),
$prompt_key,
$label_a,
$label_b
)
),
)
);
}
/**
* Fetch the body text for a specific version number.
* Version 0 means the factory default (in-code canonical body).
*
* @param string $key Registry key.
* @param int $version Version number, or 0 for factory default.
* @return string|null Body text, or null when not found.
*/
private static function fetch_version_body( string $key, int $version ): ?string {
if ( 0 === $version ) {
return PRAutoBlogger_Prompt_Registry::default_body( $key );
}
$rows = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key );
foreach ( $rows as $row ) {
if ( (int) $row['version'] === $version ) {
return (string) $row['body'];
}
}
return null;
}
/**
* Compute a line-level diff of two text bodies.
*
* Uses a simple LCS-based diff (no external binary). Returns an array of
* {type: 'added'|'removed'|'context', text: string} objects with
* text already esc_html'd. Context lines are capped at 3 surrounding
* changed lines to keep response size bounded.
*
* @param string $old Old text body.
* @param string $new New text body.
* @return array<int, array{type: string, text: string}>
*/
private static function compute_diff( string $old, string $new ): array {
$old_lines = explode( "\n", $old );
$new_lines = explode( "\n", $new );
$m = count( $old_lines );
$n = count( $new_lines );
// LCS table — for reasonable-sized prompts (typically < 200 lines).
$lcs = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) );
for ( $i = 1; $i <= $m; $i++ ) {
for ( $j = 1; $j <= $n; $j++ ) {
if ( $old_lines[ $i - 1 ] === $new_lines[ $j - 1 ] ) {
$lcs[ $i ][ $j ] = $lcs[ $i - 1 ][ $j - 1 ] + 1;
} else {
$lcs[ $i ][ $j ] = max( $lcs[ $i - 1 ][ $j ], $lcs[ $i ][ $j - 1 ] );
}
}
}
// Backtrack to build edit ops.
$ops = array();
$i = $m;
$j = $n;
while ( $i > 0 || $j > 0 ) {
if ( $i > 0 && $j > 0 && $old_lines[ $i - 1 ] === $new_lines[ $j - 1 ] ) {
array_unshift( $ops, array( 'context', $old_lines[ $i - 1 ] ) );
--$i;
--$j;
} elseif ( $j > 0 && ( 0 === $i || $lcs[ $i ][ $j - 1 ] >= $lcs[ $i - 1 ][ $j ] ) ) {
array_unshift( $ops, array( 'added', $new_lines[ $j - 1 ] ) );
--$j;
} else {
array_unshift( $ops, array( 'removed', $old_lines[ $i - 1 ] ) );
--$i;
}
}
// Collapse context: keep 3 lines before/after each changed block.
$context_radius = 3;
$changed = array();
foreach ( $ops as $idx => $op ) {
if ( 'context' !== $op[0] ) {
$changed[ $idx ] = true;
}
}
$result = array();
$skipped = 0;
foreach ( $ops as $idx => $op ) {
$near_change = false;
for ( $d = -$context_radius; $d <= $context_radius; $d++ ) {
if ( isset( $changed[ $idx + $d ] ) ) {
$near_change = true;
break;
}
}
if ( 'context' === $op[0] && ! $near_change ) {
$skipped++;
continue;
}
if ( $skipped > 0 ) {
$result[] = array(
'type' => 'omitted',
'text' => esc_html(
sprintf(
/* translators: %d: number of skipped context lines */
__( '… %d lines unchanged …', 'prautoblogger' ),
$skipped
)
),
);
$skipped = 0;
}
$result[] = array(
'type' => $op[0],
'text' => esc_html( $op[1] ),
);
}
if ( $skipped > 0 ) {
$result[] = array(
'type' => 'omitted',
'text' => esc_html(
sprintf(
/* translators: %d: number of skipped context lines */
__( '… %d lines unchanged …', 'prautoblogger' ),
$skipped
)
),
);
}
return $result;
}
/**
* Resolve a slug back to the canonical registry key via the step map allowlist.
*
* @param string $slug Slug from POST (after sanitize_key).
* @return string|null Canonical key, or null when not in the allowlist.
*/
private static function resolve_key_from_slug( string $slug ): ?string {
foreach ( PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys() as $key ) {
if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) {
return $key;
}
}
return null;
}
}