PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / full-site-import / Utils / AIUtils.php

AIUtils.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/full-site-import/Utils/AIUtils.php

1,591 lines 53.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Modules\FullSiteImport\Utils;
4
5 use Templately\Utils\Options;
6 use Templately\Utils\Helper;
7
8 /**
9 * AI-related utility functions for Templately
10 * Handles AI process data management using API key-based storage with count-based cleanup
11 */
12 class AIUtils {
13
14 /**
15 * Sanitize path component for security - prevents path traversal attacks
16 *
17 * @param mixed $value The value to sanitize (string or numeric)
18 * @param string $type Type for error message ('session_id', 'content_id', 'path_key')
19 * @return string|\WP_Error Sanitized value or error if invalid
20 */
21 public static function sanitize_path_component($value, $type = 'path_component') {
22 // Convert numeric values to string (content IDs like 136 are valid)
23 if (is_numeric($value)) {
24 $value = (string) $value;
25 }
26
27 // Check for empty or non-string values
28 if (empty($value) || !is_string($value)) {
29 return Helper::error(
30 'invalid_' . $type,
31 sprintf(__('Invalid %s: empty or not a valid value.', 'templately'), $type),
32 'sanitize_path_component',
33 400
34 );
35 }
36
37 // Check for path traversal attempts BEFORE sanitizing
38 if (strpos($value, '..') !== false ||
39 strpos($value, '/') !== false ||
40 strpos($value, '\\') !== false) {
41 return Helper::error(
42 'invalid_' . $type,
43 sprintf(__('Invalid %s: contains path separators.', 'templately'), $type),
44 'sanitize_path_component',
45 400
46 );
47 }
48
49 // Apply WordPress sanitize_file_name for additional safety
50 return sanitize_file_name($value);
51 }
52
53 /**
54 * Validate that a file path is within WordPress upload directory
55 * Prevents path traversal attacks
56 *
57 * @param string $file_path The file path to validate
58 * @return bool|WP_Error True if valid, WP_Error otherwise
59 */
60 public static function validate_file_path($file_path) {
61 // Get WordPress upload directory
62 $upload_dir = wp_upload_dir();
63 $real_upload_base = realpath($upload_dir['basedir']);
64
65 if ($real_upload_base === false) {
66 return Helper::error(
67 'invalid_upload_dir',
68 __('WordPress upload directory is not accessible.', 'templately'),
69 'validate_file_path',
70 500
71 );
72 }
73
74 // For file path, check the directory if file doesn't exist yet
75 $dir_to_check = dirname($file_path);
76
77 // Create directory if it doesn't exist (for pre-write validation)
78 if (!file_exists($dir_to_check)) {
79 wp_mkdir_p($dir_to_check);
80 }
81
82 $real_path = realpath($dir_to_check);
83 if ($real_path === false) {
84 return Helper::error(
85 'path_not_exists',
86 __('File path does not exist or is not accessible.', 'templately'),
87 'validate_file_path',
88 400
89 );
90 }
91
92 // Normalize paths with trailing directory separator
93 $normalized_upload_base = rtrim($real_upload_base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
94 $normalized_path = rtrim($real_path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
95
96 // Check if path starts with upload directory (PHP 8+ style with polyfill)
97 // This prevents partial matches like /var/www/uploads-backup matching /var/www/uploads
98 $is_subdirectory = false;
99 if (function_exists('str_starts_with')) {
100 // PHP 8+
101 $is_subdirectory = str_starts_with($normalized_path, $normalized_upload_base);
102 } else {
103 // PHP 7.4 polyfill
104 $is_subdirectory = substr($normalized_path, 0, strlen($normalized_upload_base)) === $normalized_upload_base;
105 }
106
107 if (!$is_subdirectory) {
108 return Helper::error(
109 'path_outside_uploads',
110 __('Invalid file path: path is outside WordPress upload directory.', 'templately'),
111 'validate_file_path',
112 400
113 );
114 }
115
116 return true;
117 }
118
119 /**
120 * Handle SSE message wait with timeout exit condition
121 * Static utility function for reusable timeout handling across different import contexts
122 *
123 * @param string $session_id Session ID for progress tracking
124 * @param string $progress_id Progress tracking identifier (e.g., 'ai_content_time', 'finalize_time')
125 * @param array $updated_ids Currently updated/processed pages
126 * @param array $ai_page_ids All AI pages that need processing. MUST be a FLAT
127 * list of content ids — this is counted against the
128 * number of processed pages. Passing the nested
129 * `type/sub_type => [...]` map counts groups, not
130 * pages, and the wait silently never engages. Use
131 * AIUtils::flatten_ai_page_ids().
132 * @param callable $sse_message_callback Callback function for sending SSE messages
133 * @param array $additional_sse_data Additional data to include in SSE message
134 * @param string|null $old_template_id The AI page id being waited on. Retained for
135 * signature/back-compat; on-demand pulling now
136 * runs in AIContentResolver providers before this.
137 * @return bool True if should continue processing, false if should exit
138 */
139 public static function handle_sse_wait_with_timeout($session_id, $progress_id, $updated_ids, $ai_page_ids, $sse_message_callback, $additional_sse_data = [], $old_template_id = null, $timeout_seconds = 420) {
140 // Both callers hand this the GROUPED map — ['content/page' => [6, 10, …],
141 // 'templates' => [21, …]] — so a raw count() yields the number of groups
142 // (2), not of pages (9), and the "everything is done" check below passes
143 // the moment 3 pages exist. Flatten first. This stayed invisible while the
144 // pages were always written before the import started; it only bites once
145 // the writes can land mid-import, where it silently drops the AI content
146 // and reports success.
147 $ai_page_ids = self::flatten_ai_page_ids($ai_page_ids);
148 $total_pages = count($ai_page_ids);
149 $updated_pages = count($updated_ids['pages'] ?? []);
150
151 // If all pages are processed or credit cost is available, continue processing
152 if ($total_pages <= $updated_pages || isset($updated_ids['credit_cost'])) {
153 return true;
154 }
155
156 // Get timeout tracking data from session
157 $session_data = SessionData::get_data($session_id);
158 $progress_data = $session_data['progress'][$progress_id] ?? [];
159 $last_progress = $progress_data['last_progress'] ?? 0;
160 $last_time = $progress_data['last_time'] ?? 0;
161 $current_time = time();
162 $progress_percentage = $total_pages > 0 ? round(($updated_pages / $total_pages) * 100) : 0;
163
164 // Skip timeout check if credit cost is available and time difference is > 10 seconds
165 if (isset($updated_ids['credit_cost']) && !empty($last_time) && ($current_time - $last_time) > 10) {
166 return true;
167 }
168
169 // Check if time difference is less than timeout(default 7 minutes) (timeout condition)
170 if (empty($last_time) || ($current_time - $last_time) < $timeout_seconds) {
171 // NOTE: on-demand pulling of the page (classic local-site poll, chat-id
172 // pull, etc.) now happens in the source providers dispatched by
173 // AIContentResolver *before* this wait handler is reached. Here we only
174 // track progress and emit the shared SSE `wait`.
175
176 // Only update time if progress has changed
177 if ($progress_percentage !== $last_progress) {
178 $updated_progress = $session_data['progress'] ?? [];
179 $updated_progress[$progress_id] = [
180 'last_progress' => $progress_percentage,
181 'last_time' => $current_time,
182 ];
183 SessionData::set($session_id, 'progress', $updated_progress);
184 }
185
186 // NO `updateLog` here — the wait must not put a row in the progress list.
187 //
188 // It used to emit `type: 'ai-content'`, and since the client appends any step
189 // type it has not seen (applyStepUpdate in engine/steps.ts) that arrived as a
190 // NEW row below "Finalizing Your Imports". This is the only place that type is
191 // ever emitted: nothing takes it to 100 and nothing removes it, so the row could
192 // only ever be born, and it sat there spinning through the rest of the import
193 // and past completion. A checklist whose last line never ticks reads as a stall,
194 // which is the opposite of the reassurance it was added for — and it narrated an
195 // internal wait the user has no action to take about.
196 //
197 // The wait itself is unchanged, and so is the liveness it was protecting: the
198 // step that is genuinely running ("Finalizing Your Imports") stays on screen
199 // spinning, and advances as the pages either arrive or time out into the pack's
200 // default content. The progress figure is still recorded in the session above,
201 // where the timeout logic reads it.
202
203 // Prepare SSE message data
204 $sse_data = array_merge([
205 'type' => 'wait',
206 'action' => 'wait',
207 'generated_pages' => $updated_ids,
208 'all_pages' => $ai_page_ids,
209 ], $additional_sse_data);
210
211 // Send wait message and exit
212 call_user_func($sse_message_callback, $sse_data);
213 exit;
214 }
215
216 // Timeout exceeded → proceed. This "timeout → continue" fallthrough is the only
217 // terminal outcome of the single timeout regime ($timeout_seconds). 035 D3
218 // removed the dead commented-out "Taking too long…" error branch that had
219 // implied a second, never-taken regime here.
220 //
221 // Waiting is the normal path; reaching here means the page never arrived
222 // within $timeout_seconds, so the caller will import the pack's DEFAULT
223 // content for it. Log it — this used to be entirely silent, which made a
224 // defaulted page impossible to diagnose after the fact.
225 Helper::log(sprintf(
226 'ai_wait[%s/%s] timed out after %ds — page %s falls back to pack default content (%d/%d pages ready)',
227 $session_id,
228 $progress_id,
229 $timeout_seconds,
230 $old_template_id !== null ? $old_template_id : 'n/a',
231 $updated_pages,
232 $total_pages
233 ), 'ai-import', 'error');
234 return true;
235 }
236
237 /**
238 * Unified remote pull (035 US1, D2): the SINGLE GET → HTTP-status triage → decode
239 * routine behind both `poll_for_template` and `poll_for_logo_generation`. Each
240 * caller does ONE pull per invocation (the retry loop is external — the client
241 * poller / the SSE-wait re-invocation); this only removes the duplicated
242 * request + triage + decode, never adds a loop.
243 *
244 * Strictness preserves each caller's historical triage exactly:
245 * - lenient ($spec['strict_non200'] = false, the template poll): a transport
246 * error / other non-200 / non-success body is TRANSIENT (returns `false` so
247 * the caller keeps polling); only 401/403/404 are terminal (a `WP_Error`).
248 * - strict ($spec['strict_non200'] = true, the logo poll): a transport error
249 * is surfaced and ANY non-200 / non-success body is a terminal `WP_Error`.
250 *
251 * @param string $process_id The AI process ID (for terminal error context).
252 * @param string $session_id The session ID (unused here; kept for caller symmetry).
253 * @param array $spec { endpoint:string, strict_non200?:bool, context?:string }
254 * @return array{data:array}|WP_Error|false Decoded `data` on a 200 success
255 * response; `WP_Error` on a terminal failure; `false` on a transient condition
256 * (lenient callers only).
257 */
258 private static function pull_remote_artifacts($process_id, $session_id, array $spec) {
259 $endpoint = $spec['endpoint'];
260 $strict = !empty($spec['strict_non200']);
261 $context = $spec['context'] ?? 'pull_remote_artifacts';
262
263 // `unwrap => false`: this poller reads the WHOLE body — `status`, `templates`,
264 // `credit_cost` and friends sit alongside `data`, so unwrapping to `data` would
265 // hide the fields the callers below actually branch on.
266 $normalized = Helper::api_get($endpoint, [], [], 30, ['unwrap' => false]);
267
268 if ($normalized->is_error()) {
269 $error = $normalized->error();
270
271 if ($strict) {
272 // Logo poll: any failure is terminal (unchanged behavior).
273 return $error;
274 }
275
276 // Template poll: keep polling only while the failure is RETRYABLE.
277 //
278 // This used to be a hardcoded list — 401/403/404 terminal, everything else
279 // transient — which is precisely what the registry's `retryable` flag
280 // encodes, and it encodes it in one place instead of per call site. An
281 // expired session or a missing generation never resolves by waiting; a 5xx
282 // or a dropped connection usually does.
283 if ($error->is_retryable()) {
284 return false;
285 }
286
287 return new \WP_Error(
288 'ai_poll_terminal_error',
289 $error->message() ?: __('AI content generation could not be retrieved.', 'templately'),
290 ['status' => $error->status(), 'process_id' => $process_id, 'code' => $error->code()]
291 );
292 }
293
294 $api_data = $normalized->payload();
295 if (!isset($api_data['status']) || $api_data['status'] !== 'success') {
296 return $strict
297 ? Helper::error('api_response_error', __('API returned an error response.', 'templately'), $context, 400)
298 : false;
299 }
300
301 return ['data' => $api_data['data'] ?? []];
302 }
303
304 /**
305 * Poll for logo generation status on local sites — a THIN caller of
306 * `pull_remote_artifacts` (035 US1). Makes one GET and returns the logo data.
307 *
308 * @param string $process_id The logo generation process ID
309 * @return array|WP_Error Logo generation data (images, credit_cost) or error
310 */
311 public static function poll_for_logo_generation($process_id) {
312 if (empty($process_id)) {
313 return Helper::error(
314 'invalid_process_id',
315 __('Process ID is required for logo polling.', 'templately'),
316 'poll_for_logo_generation',
317 400
318 );
319 }
320
321 $result = self::pull_remote_artifacts($process_id, '', [
322 'endpoint' => "v2/get-generated-logo/{$process_id}",
323 'strict_non200' => true,
324 'context' => 'poll_for_logo_generation',
325 ]);
326
327 if (is_wp_error($result)) {
328 return $result;
329 }
330
331 // Return the logo generation data (images array, credit_cost, etc.)
332 return [
333 'status' => 'success',
334 'data' => $result['data'],
335 ];
336 }
337
338 /**
339 * Poll for AI template generation status on local sites
340 * Generic polling utility that processes all available templates
341 *
342 * @param string $process_id The AI process ID
343 * @param string $session_id The session ID
344 * @param array $ai_page_ids The AI page IDs structure
345 * @return bool True if polling was successful, false otherwise
346 */
347 public static function poll_for_template($process_id, $session_id, $ai_page_ids) {
348 // First check if polling is already complete.
349 //
350 // NOTE this is a HARD short-circuit: once `is_last_part` is on the row, no further
351 // remote call is ever made for this process. If the cloud reported "last part" while
352 // pages were still unwritten, the process is stuck in that state permanently — the
353 // client then reports "N pages still missing when process marked as complete" on
354 // every retry, and no amount of re-polling can recover it. Log the short-circuit so
355 // that state is visible rather than inferred from silence.
356 $existing_record = self::read_process($process_id);
357 if (!empty($existing_record['is_last_part'])) {
358 Helper::log(
359 sprintf('poll_for_template: %s already marked last-part — no remote call made', $process_id),
360 'poll_for_template',
361 'debug'
362 );
363 return true;
364 }
365
366 // One GET + status triage + decode via the shared routine (lenient: transient
367 // transport/5xx → keep polling; 401/403/404 → terminal WP_Error).
368 $result = self::pull_remote_artifacts($process_id, $session_id, [
369 'endpoint' => "v2/ai/{$process_id}/template",
370 'strict_non200' => false,
371 'context' => 'poll_for_template',
372 ]);
373
374 if ($result === false) {
375 return false; // transient — caller may keep polling
376 }
377 if (is_wp_error($result)) {
378 return $result; // terminal (401/403/404)
379 }
380
381 $response_data = $result['data'];
382
383 // Extract required fields from API response
384 $is_last_part = $response_data['is_last_part'] ?? false;
385 $credit_cost = $response_data['credit_cost'] ?? 0;
386 $templates = $response_data['templates'] ?? [];
387
388 Helper::log(
389 sprintf(
390 'poll_for_template: %s → %d template(s), is_last_part=%s, credit_cost=%s',
391 $process_id,
392 is_array($templates) ? count($templates) : 0,
393 $is_last_part ? 'yes' : 'no',
394 (string) $credit_cost
395 ),
396 'poll_for_template',
397 'info'
398 );
399
400 // Save credit_cost / is_last_part by folding them into the process row.
401 $patch = [];
402 if ($credit_cost > 0) {
403 $patch['credit_cost'] = $credit_cost;
404 }
405 if ($is_last_part) {
406 $patch['is_last_part'] = $is_last_part;
407 }
408 if (!empty($patch)) {
409 self::merge_process_row($process_id, $patch);
410 }
411
412 // Process ALL templates if available (extracted from FullSiteImport::ai_poll_template)
413 if (!empty($templates) && is_array($templates)) {
414 foreach ($templates as $content_id => $template_data) {
415 // Save template to file using the common helper function
416 $result = self::save_template_to_file(
417 $process_id,
418 $session_id,
419 $content_id,
420 $template_data,
421 $ai_page_ids,
422 false // Not skipped
423 );
424 // Continue processing other templates even if one fails
425 }
426 }
427
428 return true; // Return true if polling was successful, regardless of specific templates
429 }
430
431 /**
432 * Get AI process data from WordPress options
433 *
434 * @return array The AI process data array
435 */
436 public static function get_ai_process_data() {
437 // 026: aggregate the per-process rows.
438 return self::get_all_processes();
439 }
440
441 /**
442 * Persist AI process records. Each entry is written as its own
443 * non-autoloaded per-process row (026 seam), preserving an existing
444 * session linkage if the incoming record omits it.
445 *
446 * @param array $data The AI process data to save (process_id => process_data)
447 * @return bool True on success, false on failure
448 */
449 public static function update_ai_process_data($data) {
450 if (!is_array($data)) {
451 return false;
452 }
453
454 $ok = true;
455 foreach ($data as $process_id => $process_data) {
456 if (!is_array($process_data)) {
457 continue;
458 }
459 // Don't drop a session linkage already written to the row.
460 if (empty($process_data['session_id'])) {
461 $existing = get_option(self::process_option_key($process_id), null);
462 if (is_array($existing) && !empty($existing['session_id'])) {
463 $process_data['session_id'] = $existing['session_id'];
464 }
465 }
466 $ok = self::write_process_row($process_id, $process_data) && $ok;
467 }
468
469 return $ok;
470 }
471
472 /**
473 * Fields captured by the in-plugin AI conversation (AiContentSidebar).
474 *
475 * @var string[]
476 */
477 const CONVERSATION_FIELDS = ['name', 'category', 'description', 'email', 'contactNumber', 'businessAddress', 'openingHour'];
478
479 /**
480 * Whether a stored process holds an in-plugin AI conversation.
481 *
482 * A process registered by the chat handoff (`ai-content/chatbot-import-prepare`)
483 * carries none of these fields — it exists only so the import runners can locate
484 * the pages generated on the app end. Offering it as a "previous conversation"
485 * made the sidebar render every question as "I want to skip the question",
486 * because a *present but empty* field is what marks a genuinely skipped answer.
487 *
488 * @param array $process_data The stored process data.
489 * @return bool
490 */
491 public static function has_conversation_data($process_data) {
492 if (!is_array($process_data)) {
493 return false;
494 }
495
496 foreach (self::CONVERSATION_FIELDS as $field) {
497 if (array_key_exists($field, $process_data)) {
498 return true;
499 }
500 }
501
502 return false;
503 }
504
505 /**
506 * Chatbot `detected_info` keys → conversation step keys.
507 *
508 * Mirrors DETECTED_INFO_KEY_MAP in AiContentSidebar/helper.js, including its
509 * snake_case/alias tolerance and its deliberate omission of `language` (that
510 * step has bespoke selection logic). Step keys map to themselves so a payload
511 * already keyed by step key passes through unchanged.
512 *
513 * @var array<string,string>
514 */
515 const DETECTED_INFO_KEY_MAP = [
516 'business_name' => 'name',
517 'name' => 'name',
518 'business_type' => 'category',
519 'business_niche' => 'category',
520 'business_niches' => 'category',
521 'business_industry' => 'category',
522 'category' => 'category',
523 'business_description' => 'description',
524 'description' => 'description',
525 'about' => 'description',
526 'prompt' => 'description',
527 'email' => 'email',
528 'business_email' => 'email',
529 'phone' => 'contactNumber',
530 'phone_number' => 'contactNumber',
531 'contact_number' => 'contactNumber',
532 'contactNumber' => 'contactNumber',
533 'business_address' => 'businessAddress',
534 'address' => 'businessAddress',
535 'businessAddress' => 'businessAddress',
536 'opening_hour' => 'openingHour',
537 'opening_hours' => 'openingHour',
538 'openingHour' => 'openingHour',
539 ];
540
541 /**
542 * Normalize a chatbot `detected_info` payload into conversation step keys.
543 *
544 * Accepts the plain-object shape (`{ business_name: '…' }`), the older
545 * array-of-`{key,value}` shape, and a payload already keyed by step key.
546 * Blank/non-scalar values and unknown keys are dropped; the first value wins
547 * when two aliases map to the same step.
548 *
549 * @param mixed $detected_info Raw detected info.
550 * @return array<string,string> Map of step key => sanitized value.
551 */
552 public static function map_chat_detected_info($detected_info) {
553 $mapped = [];
554
555 if (!is_array($detected_info)) {
556 return $mapped;
557 }
558
559 foreach ($detected_info as $key => $value) {
560 // Array-of-{key,value} rows.
561 if (is_array($value) && isset($value['key'])) {
562 $key = $value['key'];
563 $value = isset($value['value']) ? $value['value'] : '';
564 }
565
566 if (!is_string($key) || !isset(self::DETECTED_INFO_KEY_MAP[$key])) {
567 continue;
568 }
569
570 if (!is_scalar($value)) {
571 continue;
572 }
573
574 $value = trim(sanitize_textarea_field((string) $value));
575 if ($value === '') {
576 continue;
577 }
578
579 $step_key = self::DETECTED_INFO_KEY_MAP[$key];
580 if (isset($mapped[$step_key])) {
581 continue;
582 }
583
584 $mapped[$step_key] = $value;
585 }
586
587 return $mapped;
588 }
589
590 /**
591 * Fill in every conversation field so a stored process reads as a complete
592 * conversation: detected answers keep their value, undetected ones stay empty
593 * (which the sidebar renders as "I want to skip the question" — accurate,
594 * since the user never answered them).
595 *
596 * Returns an empty array when nothing at all was detected, so a process is
597 * never stored as a conversation where *every* answer was skipped.
598 *
599 * @param array $mapped Output of {@see map_chat_detected_info()}.
600 * @return array<string,string>
601 */
602 public static function build_conversation_fields($mapped) {
603 if (empty($mapped) || !is_array($mapped)) {
604 return [];
605 }
606
607 $fields = [];
608 foreach (self::CONVERSATION_FIELDS as $field) {
609 $fields[$field] = isset($mapped[$field]) ? $mapped[$field] : '';
610 }
611
612 return $fields;
613 }
614
615 /**
616 * Get the latest AI process data for the current API key or user ID
617 * Used in import_info() to return the most recent AI process
618 * Priority: api_key first, then user_id as fallback
619 * Only processes carrying an in-plugin conversation are considered.
620 *
621 * @return array|null The latest AI process data or null if not found
622 */
623 public static function get_latest_ai_process_by_api_key($id) {
624 $api_key = Options::get_instance()->get('api_key');
625 $user = Options::get_instance()->get('user');
626 $user_id = isset($user['id']) ? $user['id'] : null;
627
628 $all_ai_process_data = self::get_all_processes();
629 if (empty($all_ai_process_data)) {
630 return null;
631 }
632
633 $matching_processes = [];
634
635 // Priority 1: Try to find processes by API key if available
636 if (!empty($api_key)) {
637 foreach ($all_ai_process_data as $process_id => $process_data) {
638 if (is_array($process_data) && isset($process_data['api_key']) && $process_data['api_key'] === $api_key && self::has_conversation_data($process_data)) {
639 $matching_processes[$process_id] = $process_data;
640 }
641 }
642 }
643
644 // Priority 2: If no API key matches found or API key is empty, fallback to user_id
645 if (empty($matching_processes) && !empty($user_id)) {
646 foreach ($all_ai_process_data as $process_id => $process_data) {
647 if (is_array($process_data) && isset($process_data['user_id']) && $process_data['user_id'] === $user_id && self::has_conversation_data($process_data)) {
648 $matching_processes[$process_id] = $process_data;
649 }
650 }
651 }
652
653 if (empty($matching_processes)) {
654 return null;
655 }
656
657 // Most recent by write time (per-process rows are no longer insertion-ordered).
658 uasort($matching_processes, function ($a, $b) {
659 return ($a['_updated_at'] ?? 0) <=> ($b['_updated_at'] ?? 0);
660 });
661 $latest_data = end($matching_processes);
662
663 if($id != ($latest_data['pack_id'] ?? null) && isset($latest_data['imageReplace'])){
664 unset($latest_data['imageReplace']);
665 }
666
667 // Return the last (most recent) process
668 return $latest_data;
669 }
670
671
672
673 /**
674 * Get AI process data by session ID
675 *
676 * @param string $session_id The session ID to search for
677 * @return array|null The AI process data array or null if not found
678 */
679 public static function get_ai_process_data_by_session_id($session_id) {
680 if (empty($session_id)) {
681 return null;
682 }
683
684 $ai_process_data = self::get_ai_process_data();
685
686 foreach ($ai_process_data as $process) {
687 if (is_array($process) && isset($process['session_id']) && $process['session_id'] === $session_id) {
688 return $process;
689 }
690 }
691
692 return null;
693 }
694
695 /**
696 * Get AI process ID by session ID
697 *
698 * @param string $session_id The session ID to search for
699 * @return string|null The process ID (array key) or null if not found
700 */
701 public static function get_ai_process_id_by_session_id($session_id) {
702 if (empty($session_id)) {
703 return null;
704 }
705
706 $ai_process_data = self::get_ai_process_data();
707
708 foreach ($ai_process_data as $process_id => $process) {
709 if (is_array($process) && isset($process['session_id']) && $process['session_id'] === $session_id) {
710 return $process_id;
711 }
712 }
713
714 return null;
715 }
716
717 /**
718 * Get AI process data by process ID
719 *
720 * @param string $process_id The process ID to search for
721 * @return array|null The AI process data array or null if not found
722 */
723 public static function get_ai_process_data_by_process_id($process_id) {
724 if (empty($process_id)) {
725 return null;
726 }
727
728 // 026: single-row read (merged) instead of a full scan.
729 return self::read_process($process_id);
730 }
731
732 /**
733 * Clean AI process data by pack ID, keeping only the current process
734 * Removes all AI process entries with the same pack_id except the current process
735 *
736 * @param string $pack_id The pack ID to match for cleanup
737 * @param string $current_process_id The current process ID to preserve (optional)
738 * @return array Array of removed process IDs
739 */
740 public static function clean_ai_process_data_by_pack_id($pack_id, $current_process_id = null) {
741 if (empty($pack_id)) {
742 return [];
743 }
744
745 $all_ai_process_data = self::get_all_processes();
746 if (empty($all_ai_process_data)) {
747 return [];
748 }
749
750 $removed_process_ids = [];
751
752 foreach ($all_ai_process_data as $process_id => $process_data) {
753 if (!is_array($process_data)) {
754 continue;
755 }
756
757 // Skip the current process by process_id if provided
758 if (!empty($current_process_id) && $process_id === $current_process_id) {
759 continue;
760 }
761
762 // Remove processes that have the same pack_id (row + page sub-keys).
763 if (isset($process_data['pack_id']) && $process_data['pack_id'] === $pack_id) {
764 self::delete_process($process_id);
765 $removed_process_ids[] = $process_id;
766 }
767 }
768
769 return $removed_process_ids;
770 }
771
772 /* =====================================================================
773 * 026 — Per-process / per-page storage seam (non-autoloaded)
774 *
775 * The single read/write/merge surface for AI generation records. Replaces
776 * the two shared options (`templately_ai_process_data`,
777 * `templately_ai_processed_pages`) — which forced a read-modify-write on
778 * every callback and raced — with one row per process plus one row per
779 * completed page:
780 *
781 * templately_ai_process_{process_id} — the process record
782 * templately_ai_process_{process_id}_page_{pid} — one per page completion
783 *
784 * Every write is NON-AUTOLOADED (D4). `record_page_completion` writes a
785 * DISTINCT key (no shared row, no lock → no lost updates, SC-001).
786 * `read_process` is the ONLY place that merges sub-keys.
787 * ===================================================================== */
788
789 /** Option key for a process record. */
790 public static function process_option_key($process_id) {
791 return 'templately_ai_process_' . $process_id;
792 }
793
794 /** Option key for a single page-completion sub-row of a process. */
795 public static function page_option_key($process_id, $page_id) {
796 return 'templately_ai_process_' . $process_id . '_page_' . $page_id;
797 }
798
799 /**
800 * Write an option as NON-AUTOLOADED. For a new option `update_option`
801 * creates it with autoload='no'; for an existing one it updates the value.
802 */
803 private static function write_option_no_autoload($key, $value) {
804 return update_option($key, $value, false);
805 }
806
807 /**
808 * Write (create or replace) the process record row. $record carries the
809 * server-owned fields — session_id (FR-005), is_local_site (FR-004),
810 * answers, pack_id, ai_page_ids, credit_cost, is_last_part, error, …
811 *
812 * @param string $process_id
813 * @param array $record
814 * @return bool
815 */
816 public static function write_process_row($process_id, array $record) {
817 if (empty($process_id)) {
818 return false;
819 }
820 $record['process_id'] = $process_id;
821 $record['_updated_at'] = time();
822 return self::write_option_no_autoload(self::process_option_key($process_id), $record);
823 }
824
825 /**
826 * Record ONE page completion — called by each ai_update callback. Writes a
827 * DISTINCT per-page sub-key row; never reads-then-writes a shared row, so N
828 * concurrent callbacks for distinct pages cannot lose updates (SC-001).
829 *
830 * @param string $process_id
831 * @param string|int $page_id the content/page id this completion is for
832 * @param array $completion { content_id?, is_skipped?, payload_ref?, … }
833 * @return bool
834 */
835 public static function record_page_completion($process_id, $page_id, array $completion) {
836 if (empty($process_id) || $page_id === null || $page_id === '') {
837 return false;
838 }
839 if (!isset($completion['content_id'])) {
840 $completion['content_id'] = $page_id;
841 }
842 if (!isset($completion['_completed_at'])) {
843 $completion['_completed_at'] = time();
844 }
845 return self::write_option_no_autoload(self::page_option_key($process_id, $page_id), $completion);
846 }
847
848 /**
849 * Collect every page-completion sub-row for a process via a single LIKE
850 * query (the only place that globs the sub-keys).
851 *
852 * @return array { page_id => completion-array }
853 */
854 private static function collect_page_completions($process_id) {
855 global $wpdb;
856 $prefix = self::page_option_key($process_id, ''); // …_page_
857 $like = $wpdb->esc_like($prefix) . '%';
858 $rows = $wpdb->get_results(
859 $wpdb->prepare("SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $like),
860 ARRAY_A
861 );
862
863 $completions = [];
864 if (is_array($rows)) {
865 foreach ($rows as $row) {
866 $page_id = substr($row['option_name'], strlen($prefix));
867 $value = maybe_unserialize($row['option_value']);
868 $completions[$page_id] = is_array($value) ? $value : [];
869 }
870 }
871 return $completions;
872 }
873
874 /**
875 * Rebuild today's `templately_ai_processed_pages[$id]` shape
876 * — { pages: { content_id => is_skipped }, credit_cost?, is_last_part? } —
877 * from the per-page sub-rows + the process record, so existing consumers
878 * (Finalizer, Attachments, timeout handler) read an unchanged structure.
879 */
880 private static function build_processed_pages($process_id, $row) {
881 $completions = self::collect_page_completions($process_id);
882
883 $pages = [];
884 foreach ($completions as $page_id => $completion) {
885 $content_id = $completion['content_id'] ?? $page_id;
886 $pages[$content_id] = $completion['is_skipped'] ?? false;
887 }
888
889 $processed = ['pages' => $pages];
890 if (isset($row['credit_cost'])) {
891 $processed['credit_cost'] = $row['credit_cost'];
892 }
893 if (isset($row['is_last_part'])) {
894 $processed['is_last_part'] = $row['is_last_part'];
895 }
896 return $processed;
897 }
898
899 /**
900 * Read the process record MERGED with its page sub-keys. Returns null if unknown.
901 *
902 * @return array|null [ ...record, 'processed_pages' => { content_id => is_skipped } ]
903 */
904 public static function read_process($process_id) {
905 if (empty($process_id)) {
906 return null;
907 }
908
909 $row = get_option(self::process_option_key($process_id), null);
910 if (!is_array($row)) {
911 return null;
912 }
913
914 $row['processed_pages'] = self::build_processed_pages($process_id, $row);
915 return $row;
916 }
917
918 /**
919 * Link a process to its import session (idempotent). Writes session_id into
920 * the process row and mirrors process_id into the session, so the client no
921 * longer re-submits identifiers via import_settings (FR-005).
922 *
923 * @return bool
924 */
925 public static function link_session($process_id, $session_id) {
926 if (empty($process_id) || empty($session_id)) {
927 return false;
928 }
929
930 $row = get_option(self::process_option_key($process_id), null);
931 if (!is_array($row)) {
932 $row = [];
933 }
934
935 $row['session_id'] = $session_id;
936 $written = self::write_process_row($process_id, $row);
937
938 // Mirror the linkage onto the session so consumers that start from a
939 // session id can resolve the process without the client round-trip.
940 SessionData::set($session_id, 'process_id', $process_id);
941
942 return $written;
943 }
944
945 /**
946 * Remove a process row + all its `_page_*` sub-keys. Used by cleanup and on
947 * import success.
948 *
949 * @return bool
950 */
951 public static function delete_process($process_id) {
952 if (empty($process_id)) {
953 return false;
954 }
955
956 global $wpdb;
957 delete_option(self::process_option_key($process_id));
958
959 $prefix = self::page_option_key($process_id, '');
960 $like = $wpdb->esc_like($prefix) . '%';
961 $names = $wpdb->get_col(
962 $wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like)
963 );
964 if (is_array($names)) {
965 foreach ($names as $name) {
966 delete_option($name);
967 }
968 }
969
970 return true;
971 }
972
973 /**
974 * Merge a partial patch into a process record (read raw row → merge → write).
975 * Used to fold late-arriving fields (credit_cost, is_last_part, preview_error)
976 * into the process row without touching the page sub-keys.
977 *
978 * @return bool
979 */
980 public static function merge_process_row($process_id, array $patch) {
981 if (empty($process_id)) {
982 return false;
983 }
984 $row = get_option(self::process_option_key($process_id), null);
985 if (!is_array($row)) {
986 $row = [];
987 }
988 $row = array_merge($row, $patch);
989 return self::write_process_row($process_id, $row);
990 }
991
992 /**
993 * Every known process id (the per-process rows) — so iteration (resume lookup,
994 * pack-id cleanup) stays complete.
995 *
996 * @return string[]
997 */
998 public static function get_all_process_ids() {
999 global $wpdb;
1000
1001 $prefix = 'templately_ai_process_';
1002 $like = $wpdb->esc_like($prefix) . '%';
1003 $page_like = '%' . $wpdb->esc_like('_page_') . '%';
1004
1005 $names = $wpdb->get_col($wpdb->prepare(
1006 "SELECT option_name FROM {$wpdb->options}
1007 WHERE option_name LIKE %s AND option_name NOT LIKE %s AND option_name != %s",
1008 $like,
1009 $page_like,
1010 'templately_ai_process_data'
1011 ));
1012
1013 $ids = [];
1014 foreach ((array) $names as $name) {
1015 $ids[substr($name, strlen($prefix))] = true;
1016 }
1017
1018 return array_keys($ids);
1019 }
1020
1021 /**
1022 * Remove process rows (+ their page sub-keys) whose `_updated_at` is older
1023 * than $threshold_time, exempting any still linked to an active session.
1024 * Reads rows RAW (no read_process) so a read can't reset the timestamp and
1025 * resurrect a genuinely-expired record.
1026 *
1027 * @param int $threshold_time unix time; rows older than this are candidates
1028 * @return string[] removed process ids
1029 */
1030 public static function cleanup_expired_processes($threshold_time) {
1031 global $wpdb;
1032
1033 $prefix = 'templately_ai_process_';
1034 $like = $wpdb->esc_like($prefix) . '%';
1035 $page_like = '%' . $wpdb->esc_like('_page_') . '%';
1036
1037 $names = $wpdb->get_col($wpdb->prepare(
1038 "SELECT option_name FROM {$wpdb->options}
1039 WHERE option_name LIKE %s AND option_name NOT LIKE %s AND option_name != %s",
1040 $like,
1041 $page_like,
1042 'templately_ai_process_data'
1043 ));
1044
1045 $removed = [];
1046 foreach ((array) $names as $name) {
1047 $row = get_option($name, null);
1048 if (!is_array($row)) {
1049 continue;
1050 }
1051
1052 $updated_at = isset($row['_updated_at']) ? (int) $row['_updated_at'] : 0;
1053 if ($updated_at >= $threshold_time && $updated_at > 0) {
1054 continue; // recently active
1055 }
1056
1057 // Exempt if its linked session is still active.
1058 if (!empty($row['session_id'])) {
1059 $session = SessionData::get_data($row['session_id']);
1060 if (is_array($session) && !empty($session)) {
1061 $s_updated = isset($session['_updated_at']) ? (int) $session['_updated_at'] : 0;
1062 if ($s_updated >= $threshold_time) {
1063 continue;
1064 }
1065 }
1066 }
1067
1068 $process_id = substr($name, strlen($prefix));
1069 self::delete_process($process_id);
1070 $removed[] = $process_id;
1071 }
1072
1073 return $removed;
1074 }
1075
1076 /**
1077 * All process records keyed by process id (each merged via read_process).
1078 *
1079 * @return array { process_id => record }
1080 */
1081 public static function get_all_processes() {
1082 $result = [];
1083 foreach (self::get_all_process_ids() as $process_id) {
1084 $record = self::read_process($process_id);
1085 if (is_array($record)) {
1086 $result[$process_id] = $record;
1087 }
1088 }
1089 return $result;
1090 }
1091
1092 /**
1093 * Save template data to file
1094 *
1095 * Common function for saving templates to files, used by AI content operations
1096 *
1097 * @param string $process_id The process ID
1098 * @param string $session_id The session ID
1099 * @param string $content_id The content ID
1100 * @param string $template The template data (base64 encoded or raw)
1101 * @param array $ai_page_ids Array of AI page IDs
1102 * @param bool $is_skipped Whether the template was skipped
1103 * @return array|WP_Error Result array with status and data
1104 */
1105 public static function save_template_to_file($process_id, $session_id, $content_id, $template, $ai_page_ids, $is_skipped = false) {
1106 // Security: Sanitize session_id
1107 $session_id = self::sanitize_path_component($session_id, 'session_id');
1108 if (is_wp_error($session_id)) {
1109 return $session_id;
1110 }
1111
1112 // Security: Sanitize content_id
1113 $content_id = self::sanitize_path_component($content_id, 'content_id');
1114 if (is_wp_error($content_id)) {
1115 return $content_id;
1116 }
1117
1118
1119 // Always save to tmp directory for AI content workflow
1120 $tmp_dir = Helper::upload_dir('tmp') . $session_id . DIRECTORY_SEPARATOR;
1121
1122 // Decode template if it's base64 encoded
1123 if (! empty($template) && base64_decode($template, true) !== false) {
1124 $template = base64_decode($template);
1125 }
1126
1127 // Handle empty template (skipped)
1128 if (empty($template)) {
1129 $template = json_encode([
1130 "isSkipped" => true,
1131 ]);
1132 }
1133
1134 // Find the correct directory for the content ID. Normalize first: a scalar
1135 // group value would make in_array() throw a TypeError on PHP 8, and mixed
1136 // int/string ids make loose comparison unreliable.
1137 $ai_page_ids = self::normalize_ai_page_ids($ai_page_ids);
1138 $found_key = null;
1139 foreach ($ai_page_ids as $key => $ids) {
1140 if (in_array((string) $content_id, $ids, true)) {
1141 $found_key = $key;
1142 break;
1143 }
1144 }
1145
1146 if ($found_key === null) {
1147 return Helper::error('invalid_content_id', __('Content ID not found in AI page IDs.', 'templately'), 'save_template_to_file', 400);
1148 }
1149
1150 // Security: Sanitize found_key parts (e.g., "templates/page" -> sanitize each part)
1151 $key_parts = explode('/', $found_key);
1152 $sanitized_parts = [];
1153 foreach ($key_parts as $part) {
1154 $sanitized = self::sanitize_path_component($part, 'path_key');
1155 if (is_wp_error($sanitized)) {
1156 return $sanitized;
1157 }
1158 $sanitized_parts[] = $sanitized;
1159 }
1160 $found_key = implode(DIRECTORY_SEPARATOR, $sanitized_parts);
1161
1162 // Create directory and file path
1163 $page_dir = $tmp_dir . $found_key . DIRECTORY_SEPARATOR;
1164 $file_path = $page_dir . $content_id . '.ai.json';
1165
1166 // Security: Validate path is within expected directory before writing
1167 $validation = self::validate_file_path($file_path);
1168 if (is_wp_error($validation)) {
1169 return $validation;
1170 }
1171
1172 wp_mkdir_p($page_dir);
1173
1174 // Save the file
1175 $is_success = file_put_contents($file_path, $template);
1176
1177 if ($is_success) {
1178 // 026: record this page's completion as its own distinct sub-key row
1179 // (no shared read-modify-write → concurrent callbacks can't lose updates).
1180 self::record_page_completion($process_id, $content_id, [
1181 'content_id' => $content_id,
1182 'is_skipped' => $is_skipped,
1183 ]);
1184
1185 return [
1186 'status' => 'success',
1187 'data' => [
1188 'process_id' => $process_id,
1189 // 'file_path' => $file_path,
1190 'content_id' => $content_id,
1191 ],
1192 ];
1193 }
1194
1195 return Helper::error('file_save_failed', __('Failed to save template file.', 'templately'), 'save_template_to_file', 500);
1196 }
1197
1198 /**
1199 * Get matched session data by process ID
1200 *
1201 * Helper function to retrieve session data for a given process ID
1202 * Optimized to use AI process data which already contains session_id,
1203 * avoiding the need to load all session data.
1204 *
1205 * @param string $process_id The process ID to match
1206 * @return array|false Returns matched data array or false if not found
1207 */
1208 public static function get_matched_session_data($process_id) {
1209 if (empty($process_id)) {
1210 return false;
1211 }
1212
1213 // Get AI process data which contains the session_id
1214 $process_data = self::get_ai_process_data_by_process_id($process_id);
1215
1216 if (!empty($process_data['session_id'])) {
1217 // Direct lookup using session_id - no loading all sessions
1218 return SessionData::get_data($process_data['session_id']);
1219 }
1220
1221 return false;
1222 }
1223
1224 /**
1225 * Generate AI file paths for content processing
1226 *
1227 * @param string $session_id The session ID
1228 * @param string $type The content type (templates, content, etc.)
1229 * @param string $sub_type The content sub-type (page, post, etc.)
1230 * @param string $template_id The template ID
1231 * @param string $dir_path The base directory path for original files
1232 * @return array Array containing paths for original and AI files
1233 */
1234 public static function generate_ai_file_paths($session_id, $type, $sub_type, $template_id, $dir_path) {
1235 // Original file path
1236 $original_path = $dir_path . $type . DIRECTORY_SEPARATOR;
1237 if (!empty($sub_type)) {
1238 $original_path .= $sub_type . DIRECTORY_SEPARATOR;
1239 }
1240 $original_file = $original_path . "{$template_id}.json";
1241
1242 // AI file path in tmp directory
1243 $tmp_dir = Helper::upload_dir('tmp') . $session_id . DIRECTORY_SEPARATOR;
1244 $ai_path = $tmp_dir . $type . DIRECTORY_SEPARATOR;
1245 if (!empty($sub_type)) {
1246 $ai_path .= $sub_type . DIRECTORY_SEPARATOR;
1247 }
1248 $ai_file = $ai_path . "{$template_id}.ai.json";
1249
1250 return [
1251 'original_file' => $original_file,
1252 'ai_file_path' => $ai_file,
1253 'ai_directory' => $ai_path,
1254 'tmp_directory' => $tmp_dir,
1255 ];
1256 }
1257
1258 /**
1259 * Get AI tmp directory path for a session
1260 *
1261 * @param string $session_id The session ID
1262 * @return string The tmp directory path
1263 */
1264 public static function get_ai_tmp_directory($session_id) {
1265 return Helper::upload_dir('tmp') . $session_id . DIRECTORY_SEPARATOR;
1266 }
1267
1268 /**
1269 * Get AI file path for specific content
1270 *
1271 * @param string $session_id The session ID
1272 * @param string $type The content type
1273 * @param string $sub_type The content sub-type (optional)
1274 * @param string $content_id The content ID
1275 * @return string The AI file path
1276 */
1277 public static function get_ai_file_path($session_id, $type, $sub_type, $content_id) {
1278 $tmp_dir = self::get_ai_tmp_directory($session_id);
1279 $file_path = $tmp_dir . $type . DIRECTORY_SEPARATOR;
1280 if (!empty($sub_type)) {
1281 $file_path .= $sub_type . DIRECTORY_SEPARATOR;
1282 }
1283 return $file_path . "{$content_id}.ai.json";
1284 }
1285
1286 /**
1287 * Check if AI file exists and is valid
1288 *
1289 * @param string $ai_file_path The AI file path
1290 * @return bool True if AI file exists and is valid
1291 */
1292 public static function has_ai_file($ai_file_path) {
1293 if (!file_exists($ai_file_path)) {
1294 return false;
1295 }
1296
1297 $file_content = file_get_contents($ai_file_path);
1298 if (empty($file_content)) {
1299 return false;
1300 }
1301
1302 $decoded = json_decode($file_content, true);
1303 return json_last_error() === JSON_ERROR_NONE && !empty($decoded);
1304 }
1305
1306 /**
1307 * Check if AI file is marked as skipped
1308 *
1309 * @param string $ai_file_path The AI file path
1310 * @return bool True if AI file exists and is marked as skipped
1311 */
1312 public static function is_ai_file_skipped($ai_file_path) {
1313 if (!file_exists($ai_file_path)) {
1314 return false;
1315 }
1316
1317 $ai_content = Utils::read_json_file($ai_file_path);
1318 return isset($ai_content['isSkipped']) && $ai_content['isSkipped'];
1319 }
1320
1321 /**
1322 * Normalize an `ai_page_ids` payload into the canonical structure:
1323 * `[ 'type/sub_type' => [ 'content_id', ... ], ... ]` with ids as strings.
1324 *
1325 * The value reaches us from several places in several shapes — the JS import
1326 * FormData (a JSON string), session data (already decoded), the manifest, and
1327 * AI process data — and consumers index into it in incompatible ways. Without
1328 * normalization the failure modes are ugly and inconsistent:
1329 * - `save_template_to_file()` runs `in_array($id, $ids)` with no is_array
1330 * guard → TypeError on PHP 8 if a group value is a scalar.
1331 * - `BaseRunner::is_ai_content()` runs `array_merge` over the values → same.
1332 * - `is_ai_content()` / `find_content_type_info()` DO guard with is_array,
1333 * so a scalar group makes the page silently invisible instead — the page
1334 * imports with default content and nothing is logged.
1335 *
1336 * Accepted inputs:
1337 * - canonical map: `['content/page' => [1, 2]]`
1338 * - JSON string: `'{"content/page":[1,2]}'`
1339 * - scalar group value: `['content/page' => 1]` → `['content/page' => ['1']]`
1340 * - comma-separated: `['content/page' => '1,2']` → `['content/page' => ['1','2']]`
1341 * - flat list: `[1, 2]` (see caveat below)
1342 *
1343 * Ids become strings so membership tests can use strict comparison — PHP's
1344 * loose `in_array()` on mixed int/string ids is a known foot-gun.
1345 *
1346 * CAVEAT: a flat list carries no `type/sub_type` grouping, and grouping cannot
1347 * be invented. Such input normalizes to one id per (numeric) key, which is
1348 * fine for membership and counting via {@see flatten_ai_page_ids()} but yields
1349 * meaningless type info from {@see find_content_type_info()}. Callers that
1350 * need the directory (file paths) require a genuinely grouped payload.
1351 *
1352 * @param mixed $ai_page_ids Raw value in any of the shapes above.
1353 * @return array Canonical `type/sub_type => [id,...]` map (empty on junk input).
1354 */
1355 public static function normalize_ai_page_ids($ai_page_ids) {
1356 // FormData sends this as a JSON string; some paths decode it, some don't.
1357 if (is_string($ai_page_ids)) {
1358 $trimmed = trim($ai_page_ids);
1359 if ($trimmed === '') {
1360 return [];
1361 }
1362 $decoded = json_decode($trimmed, true);
1363 $ai_page_ids = is_array($decoded) ? $decoded : explode(',', $trimmed);
1364 }
1365
1366 if (!is_array($ai_page_ids) || empty($ai_page_ids)) {
1367 return [];
1368 }
1369
1370 $normalized = [];
1371 foreach ($ai_page_ids as $key => $ids) {
1372 // A group may arrive as a scalar, or as a comma-separated string.
1373 if (is_string($ids) && strpos($ids, ',') !== false) {
1374 $ids = explode(',', $ids);
1375 } elseif (!is_array($ids)) {
1376 $ids = [$ids];
1377 }
1378
1379 $clean = [];
1380 foreach ($ids as $id) {
1381 // Objects/arrays are not valid ids; drop rather than stringify them.
1382 if (is_array($id) || is_object($id) || is_bool($id) || $id === null) {
1383 continue;
1384 }
1385 $id = trim((string) $id);
1386 if ($id === '') {
1387 continue;
1388 }
1389 $clean[] = $id;
1390 }
1391
1392 if (!empty($clean)) {
1393 $normalized[$key] = array_values(array_unique($clean));
1394 }
1395 }
1396
1397 return $normalized;
1398 }
1399
1400 /**
1401 * Flatten an `ai_page_ids` payload into a single list of content-id strings.
1402 *
1403 * REQUIRED before handing the value to {@see handle_sse_wait_with_timeout()},
1404 * which compares `count($ai_page_ids)` against the number of processed pages.
1405 * Passing the nested map counts GROUPS (typically 2-3) rather than PAGES, so
1406 * the "everything is done" guard trips as soon as a couple of pages land and
1407 * the wait never engages — every still-generating page then silently imports
1408 * with pack default content.
1409 *
1410 * @param mixed $ai_page_ids Raw value in any shape {@see normalize_ai_page_ids()} accepts.
1411 * @return array Flat list of unique content-id strings.
1412 */
1413 public static function flatten_ai_page_ids($ai_page_ids) {
1414 $normalized = self::normalize_ai_page_ids($ai_page_ids);
1415 if (empty($normalized)) {
1416 return [];
1417 }
1418
1419 return array_values(array_unique(array_merge(...array_values($normalized))));
1420 }
1421
1422 /**
1423 * Check if content ID is in AI page IDs
1424 *
1425 * @param string $content_id The content ID to check
1426 * @param array $ai_page_ids The AI page IDs structure
1427 * @return bool True if content ID is found in AI page IDs
1428 */
1429 public static function is_ai_content($content_id, $ai_page_ids) {
1430 return in_array((string) $content_id, self::flatten_ai_page_ids($ai_page_ids), true);
1431 }
1432
1433 /**
1434 * Check if content should be processed as AI content
1435 *
1436 * @param string $session_id The session ID
1437 * @param string $type The content type
1438 * @param string $sub_type The content sub-type
1439 * @param string $content_id The content ID
1440 * @param array $ai_page_ids The AI page IDs structure
1441 * @param string $dir_path The base directory path
1442 * @return bool True if this is AI content
1443 */
1444 public static function should_process_as_ai_content($session_id, $type, $sub_type, $content_id, $ai_page_ids, $dir_path) {
1445 // Check if content ID is in AI page IDs list
1446 $is_ai_template = self::is_ai_content($content_id, $ai_page_ids);
1447
1448 // Check if AI file exists
1449 $paths = self::generate_ai_file_paths($session_id, $type, $sub_type, $content_id, $dir_path);
1450 $has_ai_file = self::has_ai_file($paths['ai_file_path']);
1451
1452 return $is_ai_template || $has_ai_file;
1453 }
1454
1455 /**
1456 * Validate and extract AI process data for a given process ID
1457 *
1458 * @param string $process_id The process ID
1459 * @return array|WP_Error Returns process data array or WP_Error on failure
1460 */
1461 public static function validate_and_get_process_data($process_id) {
1462 if (empty($process_id)) {
1463 return Helper::error('invalid_process_id', __('Process ID is required.', 'templately'), 'validate_process_data', 400);
1464 }
1465
1466 $process_data = self::read_process($process_id);
1467 if (empty($process_data)) {
1468 return Helper::error('process_not_found', __('Process ID not found.', 'templately'), 'validate_process_data', 404);
1469 }
1470
1471 // Validate required fields
1472 if (empty($process_data['session_id'])) {
1473 return Helper::error('missing_session_id', __('Session ID missing from process data.', 'templately'), 'validate_process_data', 400);
1474 }
1475
1476 if (empty($process_data['ai_page_ids'])) {
1477 return Helper::error('missing_ai_page_ids', __('AI page IDs missing from process data.', 'templately'), 'validate_process_data', 400);
1478 }
1479
1480 return $process_data;
1481 }
1482
1483 /**
1484 * Get processed pages data for a process ID
1485 *
1486 * @param string $process_id The process ID
1487 * @return array The processed pages data
1488 */
1489 public static function get_processed_pages_data($process_id) {
1490 $record = self::read_process($process_id);
1491 return is_array($record) && isset($record['processed_pages']) ? $record['processed_pages'] : [];
1492 }
1493
1494 /**
1495 * Update processed pages data for a process ID
1496 *
1497 * @param string $process_id The process ID
1498 * @param array $data The data to update
1499 * @return bool True on success, false on failure
1500 */
1501 public static function update_processed_pages_data($process_id, $data) {
1502 // 026: process-level fields (credit_cost / is_last_part) fold into the row;
1503 // per-page completions go through record_page_completion, not here.
1504 if (!is_array($data)) {
1505 return false;
1506 }
1507 $patch = $data;
1508 unset($patch['pages']);
1509 if (empty($patch)) {
1510 return true;
1511 }
1512 return self::merge_process_row($process_id, $patch);
1513 }
1514
1515 /**
1516 * Find content type and sub-type for a given content ID in AI page IDs
1517 *
1518 * @param string $content_id The content ID to find
1519 * @param array $ai_page_ids The AI page IDs structure
1520 * @return array|null Array with 'type' and 'sub_type' keys, or null if not found
1521 */
1522 public static function find_content_type_info($content_id, $ai_page_ids) {
1523 $ai_page_ids = self::normalize_ai_page_ids($ai_page_ids);
1524 if (empty($ai_page_ids)) {
1525 return null;
1526 }
1527
1528 foreach ($ai_page_ids as $key => $ids) {
1529 if (in_array((string) $content_id, $ids, true)) {
1530 $type_parts = explode('/', $key);
1531 return [
1532 'type' => $type_parts[0],
1533 'sub_type' => isset($type_parts[1]) ? $type_parts[1] : '',
1534 'key' => $key
1535 ];
1536 }
1537 }
1538
1539 return null;
1540 }
1541
1542 /**
1543 * Read AI template data directly from files
1544 * Common function for reading template data, used by AI content operations
1545 *
1546 * @param string $session_id The session ID
1547 * @param array $ai_page_ids The AI page IDs structure
1548 * @param string $dir_path The base directory path
1549 * @return array Array of template data indexed by content ID
1550 */
1551 public static function read_ai_template_data($session_id, $ai_page_ids, $dir_path) {
1552 $result = [];
1553
1554 $ai_page_ids = self::normalize_ai_page_ids($ai_page_ids);
1555 if (empty($ai_page_ids)) {
1556 return $result;
1557 }
1558
1559 foreach ($ai_page_ids as $key => $ids) {
1560 foreach ($ids as $id) {
1561 $type_arr = explode('/', $key);
1562 $type = $type_arr[0];
1563 $sub_type = isset($type_arr[1]) ? $type_arr[1] : '';
1564
1565 // 037 US4 / FR-008: every $id here is drawn from $ai_page_ids, so
1566 // should_process_as_ai_content() (is_ai_content || has_ai_file) was
1567 // UNCONDITIONALLY true in this loop — its has_ai_file() read+decode
1568 // (and the duplicate generate_ai_file_paths()) were redundant
1569 // per-poll filesystem work the completion record already answers.
1570 // File presence remains the include-gate (byte-identical output),
1571 // and the file is now decoded exactly once — for the result.
1572 $ai_paths = self::generate_ai_file_paths($session_id, $type, $sub_type, $id, $dir_path);
1573 $ai_file_path = $ai_paths['ai_file_path'];
1574
1575 if (file_exists($ai_file_path)) {
1576 $ai_content = Utils::read_json_file($ai_file_path);
1577 if (isset($ai_content['isSkipped']) && $ai_content['isSkipped']) {
1578 // Return empty array for skipped content (for JS compatibility)
1579 $result[$id] = [];
1580 } else {
1581 $result[$id] = $ai_content; // Raw AI content
1582 }
1583 }
1584 }
1585 }
1586
1587 return $result;
1588 }
1589
1590 }
1591