| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Modules\FullSiteImport\Utils; |
| 4 |
|
| 5 |
use Templately\Modules\FullSiteImport\Utils\Providers\ChatAIContentProvider; |
| 6 |
use Templately\Modules\FullSiteImport\Utils\Providers\ClassicAIContentProvider; |
| 7 |
use Templately\Utils\Helper; |
| 8 |
|
| 9 |
/** |
| 10 |
* Central, source-agnostic seam for making an AI-generated page's `.ai.json` |
| 11 |
* available at finalize time. |
| 12 |
* |
| 13 |
* The Finalizer no longer knows *how* a page's AI content arrives. It calls |
| 14 |
* {@see AIContentResolver::ensure_page_ready()} for every AI page; each AI |
| 15 |
* content "source" (the classic per-page `ai-update` callback flow, the chat-id |
| 16 |
* pull flow, and any future source) registers a provider on the |
| 17 |
* `templately_ai_stage_page` filter. A provider that owns the current process |
| 18 |
* pulls/writes the missing `.ai.json` and returns `true`; otherwise the shared |
| 19 |
* SSE-wait/timeout runs — identical behaviour for every source. |
| 20 |
* |
| 21 |
* Extending in the future is additive and needs no core edit: |
| 22 |
* add_filter( AIContentResolver::STAGE_HOOK, [ MyProvider::class, 'stage' ], 10, 2 ); |
| 23 |
* (optionally from a `templately_register_ai_content_providers` action listener). |
| 24 |
*/ |
| 25 |
class AIContentResolver { |
| 26 |
|
| 27 |
/** |
| 28 |
* Filter run for each AI page that still needs its `.ai.json`. |
| 29 |
* |
| 30 |
* @param bool $staged Whether a provider has already produced the file. |
| 31 |
* @param array $context See {@see ensure_page_ready()} for the shape. |
| 32 |
*/ |
| 33 |
const STAGE_HOOK = 'templately_ai_stage_page'; |
| 34 |
|
| 35 |
/** |
| 36 |
* Action fired once, right before the first stage dispatch, so external |
| 37 |
* modules can register additional providers without touching core. |
| 38 |
*/ |
| 39 |
const REGISTER_HOOK = 'templately_register_ai_content_providers'; |
| 40 |
|
| 41 |
/** |
| 42 |
* @var bool Guards one-time registration of the built-in providers. |
| 43 |
*/ |
| 44 |
private static $booted = false; |
| 45 |
|
| 46 |
/** |
| 47 |
* Register the built-in AI content providers exactly once, then let external |
| 48 |
* code add its own. Both shipped AI-content versions hook in here. |
| 49 |
* |
| 50 |
* @return void |
| 51 |
*/ |
| 52 |
public static function boot_default_providers() { |
| 53 |
if ( self::$booted ) { |
| 54 |
return; |
| 55 |
} |
| 56 |
self::$booted = true; |
| 57 |
|
| 58 |
add_filter( self::STAGE_HOOK, [ ClassicAIContentProvider::class, 'stage' ], 10, 2 ); |
| 59 |
add_filter( self::STAGE_HOOK, [ ChatAIContentProvider::class, 'stage' ], 10, 2 ); |
| 60 |
|
| 61 |
do_action( self::REGISTER_HOOK ); |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* Ensure the `.ai.json` for `$context['content_id']` is on disk, pulling it |
| 66 |
* via whichever source owns this process, then (if still absent) SSE-waiting |
| 67 |
* with timeout. |
| 68 |
* |
| 69 |
* @param array $context { |
| 70 |
* @type string $session_id Import session id. |
| 71 |
* @type string|null $process_id AI process id (keys process/progress data). |
| 72 |
* @type string|int $content_id The AI page id being finalized. |
| 73 |
* @type array $ai_page_ids `type/sub_type => [content_id,...]` map. |
| 74 |
* @type array $updated_ids `templately_ai_processed_pages[process_id]` snapshot. |
| 75 |
* @type callable $sse_callback Callback used to emit the SSE `wait` message. |
| 76 |
* @type array $additional_sse_data Extra fields merged into the SSE `wait` payload. |
| 77 |
* @type string $progress_id Progress bucket key (default `ai_content_time`). |
| 78 |
* @type int $timeout Wait timeout in seconds (default 420). |
| 79 |
* } |
| 80 |
* @return bool True to continue finalizing the page (file ready or timed out). |
| 81 |
* Note: the shared wait handler may `exit()` to stream a `wait`. |
| 82 |
*/ |
| 83 |
public static function ensure_page_ready( array $context ): bool { |
| 84 |
self::boot_default_providers(); |
| 85 |
|
| 86 |
$process_id = $context['process_id'] ?? null; |
| 87 |
$process_data = ! empty( $process_id ) ? AIUtils::get_ai_process_data_by_process_id( $process_id ) : null; |
| 88 |
$context['process_data'] = is_array( $process_data ) ? $process_data : []; |
| 89 |
|
| 90 |
// Give the owning source a chance to stage (pull + write) the page. |
| 91 |
$staged = apply_filters( self::STAGE_HOOK, false, $context ); |
| 92 |
if ( $staged === true ) { |
| 93 |
return true; |
| 94 |
} |
| 95 |
|
| 96 |
// Re-read the progress counters. The caller snapshots `updated_ids` BEFORE |
| 97 |
// calling us (Finalizer.php), but a provider may have just written several |
| 98 |
// pages — using the stale snapshot would under-report progress to the wait. |
| 99 |
if ( ! empty( $process_id ) ) { |
| 100 |
$processed_pages = get_option( 'templately_ai_processed_pages', [] ); |
| 101 |
$context['updated_ids'] = ( isset( $processed_pages[ $process_id ] ) && is_array( $processed_pages[ $process_id ] ) ) |
| 102 |
? $processed_pages[ $process_id ] |
| 103 |
: ( $context['updated_ids'] ?? [] ); |
| 104 |
} |
| 105 |
|
| 106 |
// No source produced the page yet → the shared SSE-wait/timeout, which |
| 107 |
// behaves identically regardless of where the content ultimately comes |
| 108 |
// from. This can `exit()` after emitting a `wait` message. |
| 109 |
return AIUtils::handle_sse_wait_with_timeout( |
| 110 |
$context['session_id'], |
| 111 |
$context['progress_id'] ?? 'ai_content_time', |
| 112 |
$context['updated_ids'] ?? [], |
| 113 |
AIUtils::flatten_ai_page_ids( $context['ai_page_ids'] ?? [] ), |
| 114 |
$context['sse_callback'], |
| 115 |
$context['additional_sse_data'] ?? [], |
| 116 |
$context['content_id'] ?? null, |
| 117 |
$context['timeout'] ?? 420 |
| 118 |
); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Flatten the `type/sub_type => [content_id,...]` map into a single list of |
| 123 |
* page ids. |
| 124 |
* |
| 125 |
* @deprecated Use {@see AIUtils::flatten_ai_page_ids()} — kept as a thin |
| 126 |
* delegate so existing provider/extension code keeps working. |
| 127 |
* |
| 128 |
* @param mixed $ai_page_ids Nested map, JSON string, or an already-flat list. |
| 129 |
* @return array Flat list of content-id strings. |
| 130 |
*/ |
| 131 |
public static function flatten_page_ids( $ai_page_ids ): array { |
| 132 |
return AIUtils::flatten_ai_page_ids( $ai_page_ids ); |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Whether the `.ai.json` for the context's page now exists on disk. Providers |
| 137 |
* call this after a pull attempt to report success back to the resolver. |
| 138 |
* |
| 139 |
* @param array $context The same context passed to {@see ensure_page_ready()}. |
| 140 |
* @return bool |
| 141 |
*/ |
| 142 |
public static function page_staged( array $context ): bool { |
| 143 |
$session_id = $context['session_id'] ?? ''; |
| 144 |
$content_id = $context['content_id'] ?? null; |
| 145 |
$ai_page_ids = AIUtils::normalize_ai_page_ids( $context['ai_page_ids'] ?? [] ); |
| 146 |
|
| 147 |
if ( empty( $session_id ) || $content_id === null || $content_id === '' || empty( $ai_page_ids ) ) { |
| 148 |
return false; |
| 149 |
} |
| 150 |
|
| 151 |
$tmp_dir = Helper::upload_dir( 'tmp' ) . $session_id . DIRECTORY_SEPARATOR; |
| 152 |
|
| 153 |
foreach ( $ai_page_ids as $key => $ids ) { |
| 154 |
if ( in_array( (string) $content_id, $ids, true ) ) { |
| 155 |
$sub_path = str_replace( '/', DIRECTORY_SEPARATOR, (string) $key ); |
| 156 |
$file_path = $tmp_dir . $sub_path . DIRECTORY_SEPARATOR . $content_id . '.ai.json'; |
| 157 |
if ( file_exists( $file_path ) ) { |
| 158 |
return true; |
| 159 |
} |
| 160 |
} |
| 161 |
} |
| 162 |
|
| 163 |
return false; |
| 164 |
} |
| 165 |
} |
| 166 |
|