PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.7.2
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.7.2
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 3.1.10 All 111 releases
templately / includes / API / AIContent.php

AIContent.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.7.2, at includes/API/AIContent.php

1,636 lines 59.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Templately AI Content Importer
5 *
6 * @package Templately
7 * @since 1.0.0
8 */
9
10 namespace Templately\API;
11
12 use Error;
13 use Exception;
14 use Templately\Utils\Helper;
15 use Templately\Utils\Database;
16 use WP_REST_Request;
17 use WP_Error;
18 use Templately\Core\Importer\Utils\Utils;
19 use Templately\Core\Importer\Utils\AIUtils;
20 use Templately\Core\Importer\Utils\SignatureVerifier;
21 use Templately\Core\Importer\Parsers\WXR_Parser;
22
23 class AIContent extends API {
24 private $endpoint = 'ai-content';
25 private $dev_mode = false;
26
27 /**
28 * Short-lived cache of the `v2/chatbot/generated/{chat}` bundle.
29 *
30 * That payload is large (every generated page's block JSON, served off GCP)
31 * and the direct-import handoff pulls it twice within seconds — once to read
32 * the customization, once to write the pages. Only a COMPLETE bundle is ever
33 * reused (an incomplete one has to be re-pulled to pick up new pages), and
34 * the TTL is deliberately short so the `can_import` / already-imported gate
35 * cannot go meaningfully stale.
36 */
37 const GENERATED_CACHE_KEY = 'chatbot_generated_';
38 const GENERATED_CACHE_TTL = 60;
39
40
41 /**
42 * AIContent constructor.
43 *
44 * @param string $file File path.
45 * @param array $settings Settings.
46 */
47 public function __construct() {
48
49 parent::__construct();
50
51 }
52
53 public function _permission_check(WP_REST_Request $request) {
54 $this->request = $request;
55 $this->api_key = $this->utils('options')->get( 'api_key' );
56 $process_id = $this->get_param('process_id');
57
58 $_route = $request->get_route();
59 if ('/templately/v1/ai-content/ai-update' === $_route || '/templately/v1/ai-content/ai-update-preview' === $_route) {
60 // Disabled: the headers carry X-Templately-Apikey, and Helper::log()
61 // writes to debug.log, which is web-readable on plenty of hosts. That
62 // key authorizes this very route.
63 // Helper::log( [
64 // 'headers' => $request->get_headers(),
65 // 'body' => $request->get_params(),
66 // ], 'ai_update_request' );
67
68 if (empty($process_id)) {
69 return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'calculate_credit', 400);
70 }
71
72 $header_api_key = sanitize_text_field($request->get_header('x_templately_apikey'));
73 if (empty($header_api_key)) {
74 $header_api_key = sanitize_text_field($request->get_header('X-Templately-Apikey'));
75 }
76
77 // Validate API key from header against database
78 if (empty($header_api_key)) {
79 return $this->error('missing_api_key', __('Missing API key in header.', 'templately'), 'ai-content/permission', 403);
80 }
81
82 $is_valid_key = $this->validate_api_key_in_db($header_api_key);
83 if (!$is_valid_key) {
84 return $this->error('invalid_api_key', __('Invalid API key provided in header.', 'templately'), 'ai-content/permission', 403);
85 }
86
87 // Check AI process data using API key-based storage
88 $ai_process_data = AIUtils::get_ai_process_data();
89 if (is_array($ai_process_data) && !empty($ai_process_data[$process_id])) {
90 return true;
91 }
92
93 return (bool) AIUtils::get_matched_session_data($process_id);
94 }
95
96 // // Allow access to attachments endpoint
97 // if ('/templately/v1/ai-content/attachments' === $_route) {
98 // return true;
99 // }
100 return parent::_permission_check($request);
101 }
102
103
104 public function register_routes() {
105 // $this->get( $this->endpoint . '/calculate-credit', [ $this, 'calculate_credit' ] );
106 $this->post($this->endpoint . '/modify-content', [$this, 'modify_content']);
107 $this->post($this->endpoint . '/ai-update', [$this, 'ai_update']);
108 $this->post($this->endpoint . '/ai-update-preview', [$this, 'ai_update_preview']);
109 $this->post($this->endpoint . '/generate-tagline', [$this, 'generate_tagline']);
110 $this->get($this->endpoint . '/chatbot-conversation', [$this, 'get_chatbot_conversation'], [
111 'chat' => [
112 'required' => true,
113 'sanitize_callback' => 'sanitize_text_field',
114 'validate_callback' => function($param, $request, $key) {
115 return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
116 },
117 ],
118 ]);
119 $this->get($this->endpoint . '/chatbot-generated', [$this, 'get_chatbot_generated'], [
120 'chat' => [
121 'required' => true,
122 'sanitize_callback' => 'sanitize_text_field',
123 'validate_callback' => function($param, $request, $key) {
124 return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
125 },
126 ],
127 ]);
128 $this->post($this->endpoint . '/chatbot-detected-info', [$this, 'update_chatbot_detected_info'], [
129 'chat' => [
130 'required' => true,
131 'sanitize_callback' => 'sanitize_text_field',
132 'validate_callback' => function($param, $request, $key) {
133 return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
134 },
135 ],
136 ]);
137 $this->post($this->endpoint . '/chatbot-import-prepare', [$this, 'chatbot_import_prepare']);
138 $this->post($this->endpoint . '/chatbot-mark-imported', [$this, 'mark_chatbot_imported'], [
139 'chat' => [
140 'required' => true,
141 'sanitize_callback' => 'sanitize_text_field',
142 'validate_callback' => function($param, $request, $key) {
143 return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
144 },
145 ],
146 ]);
147 $this->get($this->endpoint . '/attachments', [$this, 'get_attachments'], [
148 'type' => [
149 'default' => 'pack',
150 'required' => false,
151 'sanitize_callback' => 'sanitize_text_field',
152 ],
153 'id' => [
154 'required' => false,
155 'sanitize_callback' => 'sanitize_text_field',
156 ],
157 'pack_id' => [
158 'required' => false,
159 'sanitize_callback' => 'sanitize_text_field',
160 ],
161 ]);
162 $this->get($this->endpoint . '/images', [$this, 'search_images'], [
163 'query' => [
164 'required' => false,
165 'sanitize_callback' => 'sanitize_text_field',
166 'validate_callback' => function($param, $request, $key) {
167 return is_string($param) && strlen($param) <= 255;
168 },
169 ],
170 'orientation' => [
171 'required' => false,
172 'default' => 'all',
173 'sanitize_callback' => 'sanitize_text_field',
174 'validate_callback' => function($param, $request, $key) {
175 $allowed_orientations = ['all', 'landscape', 'portrait', 'square'];
176 return in_array($param, $allowed_orientations, true);
177 },
178 ],
179 'size' => [
180 'required' => false,
181 'default' => 'medium',
182 'sanitize_callback' => 'sanitize_text_field',
183 'validate_callback' => function($param, $request, $key) {
184 $allowed_sizes = ['small', 'medium', 'large'];
185 return in_array($param, $allowed_sizes, true);
186 },
187 ],
188 'color' => [
189 'required' => false,
190 'sanitize_callback' => 'sanitize_text_field',
191 'validate_callback' => function($param, $request, $key) {
192 return is_string($param) && strlen($param) <= 50;
193 },
194 ],
195 'page' => [
196 'required' => false,
197 'default' => 1,
198 'sanitize_callback' => 'absint',
199 'validate_callback' => function($param, $request, $key) {
200 return is_numeric($param) && $param > 0 && $param <= 1000;
201 },
202 ],
203 'per_page' => [
204 'required' => false,
205 'default' => 20,
206 'sanitize_callback' => 'absint',
207 'validate_callback' => function($param, $request, $key) {
208 return is_numeric($param) && $param > 0 && $param <= 100;
209 },
210 ],
211 ]);
212 // die(rest_url( 'templately/v1/ai-content/ai-update' ));
213 }
214
215 public function calculate_credit() {
216 $pack_id = $this->get_param('pack_id');
217
218 return [
219 'status' => 'success',
220 'data' => [
221 'availableCredit' => 100,
222 ],
223 ];
224
225 if (empty($pack_id)) {
226 return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'calculate_credit', 400);
227 }
228
229 $extra_headers = [
230 'Accept' => 'application/json',
231 ];
232 $response = Helper::make_api_get_request("v2/ai/calculate-credit/pack/$pack_id", [], $extra_headers, 30);
233
234
235 // return $response;
236 if (is_wp_error($response)) {
237 return $this->error('request_failed', __('Request failed.', 'templately'), 'calculate_credit', 500, ['error_detail' => $response->get_error_message()]);
238 }
239
240 $body = wp_remote_retrieve_body($response);
241 $data = json_decode($body, true);
242 // error status is ok
243 if (! is_array($data) || ! isset($data['status'])) {
244 return $this->error('invalid_response', __('Invalid response.', 'templately'), 'calculate_credit', 500);
245 }
246
247
248 return $data;
249 }
250
251 public function modify_content() {
252 add_filter('wp_redirect', '__return_false', 999);
253 set_time_limit(3 * MINUTE_IN_SECONDS);
254 ini_set('max_execution_time', 3 * MINUTE_IN_SECONDS);
255
256 $pack_id = $this->get_param('pack_id');
257 $isBusinessNichesNew = $this->get_param('isBusinessNichesNew', false);
258 $ai_page_ids = $this->get_param('ai_page_ids', [], null);
259 $content_ids = $this->get_param('content_ids', [], null);
260 $session_id = $this->get_param('session_id'); // Add session_id parameter
261
262 // Security: Sanitize session_id if provided
263 if (!empty($session_id)) {
264 $session_id = AIUtils::sanitize_path_component($session_id, 'session_id');
265 if (is_wp_error($session_id)) {
266 return $session_id;
267 }
268 }
269
270 $preview_pages = $this->get_param('preview_pages', [], null);
271 $image_replace = $this->get_param('imageReplace', [], null);
272 $platform = $this->get_param('platform');
273 $language = $this->get_param('language', null);
274
275 // ai content fields
276 $name = $this->get_param('name');
277 $category = $this->get_param('category');
278 $description = $this->get_param('description');
279 $email = $this->get_param('email');
280 $contactNumber = $this->get_param('contactNumber');
281 $businessAddress = $this->get_param('businessAddress');
282 $openingHour = $this->get_param('openingHour');
283 $requested_platform = $this->get_param('requested_platform', 'templately');
284
285 if (empty($pack_id)) {
286 return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'modify_content', 400);
287 }
288 if (empty($category)) {
289 return $this->error('invalid_prompt', __('Invalid prompt.', 'templately'), 'modify_content', 400);
290 }
291 if (empty($content_ids) && empty($preview_pages)) {
292 return $this->error('invalid_content_ids', __('Invalid content ids.', 'templately'), 'modify_content', 400);
293 }
294 if (empty($platform)) {
295 return $this->error('invalid_platform', __('Invalid platform.', 'templately'), 'modify_content', 400);
296 }
297
298
299 // $response = get_transient( '__templately_ai_process_id' );
300
301 // if(empty($response)) {
302 $extra_headers = [
303 'Accept' => 'application/json',
304 'x-templately-session-id' => $session_id,
305 'x-templately-requested-platform' => $requested_platform,
306 ];
307 $body_data = [
308 'business_name' => $name,
309 'business_niches' => $category,
310 'prompt' => $description,
311 'email' => $email,
312 'phone' => $contactNumber,
313 'address' => $businessAddress,
314 'openingHour' => $openingHour,
315 'pack_id' => $pack_id,
316 'content_ids' => $content_ids,
317 'platform' => $platform,
318 'preview_pages' => $preview_pages,
319 'language' => $language,
320 'callback' => defined('TEMPLATELY_CALLBACK') ? TEMPLATELY_CALLBACK . '/wp-json/templately/v1/ai-content/ai-update' : rest_url('templately/v1/ai-content/ai-update'),
321 ];
322 /**
323 * Filter body data before making API request to modify-content endpoint
324 *
325 * @since 3.5.0
326 * @param array $body_data The request body data
327 * @param WP_REST_Request $request The REST request object
328 */
329 $body_data = apply_filters( 'templately_ai_modify_content_body_data', $body_data, $this->request );
330
331 $response = Helper::make_api_post_request('v2/ai/modify-content/pack', $body_data, $extra_headers, 15 * MINUTE_IN_SECONDS);
332
333 // set_transient( '__templately_ai_process_id', $response, 60 * 60 * 24 * 30 );
334 // }
335
336 $bk_ai_business_niches = get_option('templately_ai_business_niches', []);
337 if (!empty($business_niches) && $isBusinessNichesNew && ! in_array($business_niches, $bk_ai_business_niches)) {
338 $bk_ai_business_niches[] = $business_niches;
339 update_option('templately_ai_business_niches', $bk_ai_business_niches, false);
340 }
341
342 // return $response;
343 if (is_wp_error($response)) {
344 error_log(print_r($response, true));
345 return $this->error('request_failed', __('Request failed.', 'templately'), 'modify_content', 500, ['error_data' => $response->get_error_data()]);
346 }
347
348 $body = wp_remote_retrieve_body($response);
349 $data = json_decode($body, true);
350 // error status is ok, if status is error then return as is
351 if (! is_array($data) || ! isset($data['status'])) {
352 return $this->error('invalid_response', __('Invalid response.', 'templately'), 'modify_content', 500, ['data' => $data]);
353 }
354
355 // "{"status":"success","message":"The content is being generated in the queue","process_id":"01JRQQD39GNWTNF18EWF8YH0BG-271838-pack-408"}"
356 if (isset($data['status']) && $data['status'] === 'success' && isset($data['process_id'])) {
357 $process_id = $data['process_id'];
358
359 // // Save templates to files if available using the common function
360 // if (!empty($data['templates']) && is_array($data['templates'])) {
361 // foreach ($data['templates'] as $content_id => $template_data) {
362 // // Decode template if it's base64 encoded
363 // if (! empty($template_data) && base64_decode($template_data, true) !== false) {
364 // $data['templates'][$content_id] = base64_decode($template_data);
365 // }
366
367 // if (!empty($template_data)) {
368 // AIUtils::save_template_to_file(
369 // $process_id,
370 // $content_id,
371 // $template_data,
372 // $ai_page_ids,
373 // true, // Always use preview mode for AI content workflow
374 // isset($template_data['isSkipped']) ? $template_data['isSkipped'] : false
375 // );
376 // }
377 // }
378 // }
379
380 $user = $this->utils('options')->get('user');
381
382 $ai_process_data[$process_id] = [
383 'name' => $name,
384 'category' => $category,
385 'description' => $description,
386 'email' => $email,
387 'contactNumber' => $contactNumber,
388 'businessAddress' => $businessAddress,
389 'openingHour' => $openingHour,
390 'process_id' => $process_id,
391 'pack_id' => $pack_id,
392 'ai_page_ids' => $ai_page_ids,
393 'ai_preview_ids' => $preview_pages,
394 'content_ids' => $content_ids,
395 'platform' => $platform,
396 'api_key' => $this->api_key,
397 'user_id' => isset($user['id']) ? $user['id'] : null,
398 'session_id' => $session_id,
399 'imageReplace' => $image_replace,
400 'language' => $language,
401 'requested_platform' => $requested_platform,
402 ];
403
404 // Update using API key-based storage with automatic count-based cleanup
405 AIUtils::update_ai_process_data($ai_process_data);
406
407 return [
408 'status' => 'success',
409 'message' => __('The content is being generated in the queue', 'templately'),
410 'process_id' => $process_id,
411 'templates' => !empty($data['templates']) ? $data['templates'] : null,
412 'is_local_site' => !empty($data['is_local_site']) ? $data['is_local_site'] : null,
413 ];
414 }
415
416 return $data;
417 }
418
419 public function ai_update() {
420 add_filter('wp_redirect', '__return_false', 999);
421
422 $template = $this->get_param('template');
423 $process_id = $this->get_param('process_id');
424 $template_id = $this->get_param('template_id');
425 $content_id = $this->get_param('content_id');
426 $type = $this->get_param('type');
427 $isSkipped = $this->get_param('isSkipped', false);
428 $credit_cost = $this->request->get_param('credit_cost');
429
430 error_log('process_id: ' . $process_id);
431
432 // Handle credit cost updates separately
433 if ($this->request->has_param('credit_cost')) {
434 $processed_pages = get_option("templately_ai_processed_pages", []);
435 $processed_pages[$process_id] = $processed_pages[$process_id] ?? [];
436 $processed_pages[$process_id]['credit_cost'] = $credit_cost;
437 update_option("templately_ai_processed_pages", $processed_pages, false);
438
439 return [
440 'status' => 'success',
441 'data' => [
442 'process_id' => $process_id,
443 'credit_cost' => $credit_cost,
444 ],
445 ];
446 }
447
448 // Always use preview mode for AI content workflow
449 // Validate and get process data using centralized method
450 $process_data = AIUtils::validate_and_get_process_data($process_id);
451 if (is_wp_error($process_data)) {
452 return $process_data;
453 }
454
455 $session_id = $process_data['session_id'];
456 $ai_page_ids = $process_data['ai_page_ids'];
457
458 // Use the common helper function to save the template
459 $result = AIUtils::save_template_to_file(
460 $process_id,
461 $session_id,
462 $content_id,
463 $template,
464 $ai_page_ids,
465 $isSkipped
466 );
467
468 if(is_wp_error($result)){
469 return $result;
470 }
471
472 // Return the result from the helper function
473 if (isset($result['status']) && $result['status'] === 'success') {
474 return $result;
475 }
476
477 // Return error if the helper function failed
478 return $result;
479 }
480
481 public function ai_update_preview() {
482 add_filter('wp_redirect', '__return_false', 999);
483
484 $template = $this->get_param('templates'); // Now expects an array with content_id as keys
485 $process_id = $this->get_param('process_id');
486 $isSkipped = $this->get_param('isSkipped', false);
487 $error = $this->get_param('error', null);
488
489 error_log('process_id: ' . $process_id);
490
491 if (!empty($isSkipped) || !empty($error)) {
492 // Update AI process data with error using API key-based storage
493 $ai_process_data = AIUtils::get_ai_process_data();
494 if (isset($ai_process_data[$process_id])) {
495 $ai_process_data[$process_id]['preview_error'] = $error;
496 AIUtils::update_ai_process_data($ai_process_data);
497 }
498 wp_send_json_error([
499 'status' => 'error',
500 'message' => $error,
501 ]);
502 }
503
504 // Validate template parameter is an array
505 if (!is_array($template) || empty($template)) {
506 return $this->error('invalid_template', __('Template must be a non-empty array with content_id as keys.', 'templately'), 'ai-content/ai-update-preview', 400);
507 }
508
509 // Always use preview mode for AI content workflow
510 // Validate and get process data using centralized method
511 $process_data = AIUtils::validate_and_get_process_data($process_id);
512 if (is_wp_error($process_data)) {
513 return $process_data;
514 }
515
516 $session_id = $process_data['session_id'];
517 $ai_page_ids = $process_data['ai_page_ids'];
518 $results = [];
519 $success_count = 0;
520 $error_count = 0;
521
522 // Process each content_id/template pair
523 foreach ($template as $content_id => $template_data) {
524 // Use the common helper function to save the template (always preview mode)
525 $result = AIUtils::save_template_to_file(
526 $process_id,
527 $session_id,
528 $content_id,
529 $template_data,
530 $ai_page_ids,
531 $isSkipped
532 );
533
534 $results[$content_id] = $result;
535
536 // Track success/error counts
537 if (isset($result['status']) && $result['status'] === 'success') {
538 $success_count++;
539 } else {
540 $error_count++;
541 }
542 }
543
544 // Return consolidated response
545 $overall_status = $error_count === 0 ? 'success' : ($success_count === 0 ? 'error' : 'partial_success');
546
547 // Note: No cleanup needed with API key-based storage and count-based management
548
549 return [
550 'status' => $overall_status,
551 'message' => sprintf(
552 __('Processed %d templates: %d successful, %d failed.', 'templately'),
553 count($template),
554 $success_count,
555 $error_count
556 ),
557 ];
558 }
559
560
561
562 /**
563 * Get attachments from API endpoint
564 *
565 * @return array|\WP_Error
566 */
567 public function get_attachments() {
568 // Get parameters from request
569 $type = $this->get_param('type', 'pack');
570 $id = $this->get_param('pack_id');
571 $requested_platform = $this->get_param('requested_platform', 'templately');
572
573 // Require ID parameter - return error if not provided
574 if (empty($id)) {
575 return $this->error('missing_id', __('Pack ID or ID parameter is required.', 'templately'), 'get_attachments', 400);
576 }
577
578 try {
579 // Construct API endpoint URL
580 $api_endpoint = "get-xml-attachment/{$type}/{$id}";
581
582 // Make API call
583 $extra_headers = [
584 'Accept' => 'application/xml, text/xml',
585 'x-templately-requested-platform' => $requested_platform,
586 ];
587 $response = Helper::make_api_get_request("v2/$api_endpoint", [], $extra_headers, 30);
588
589 // Check for HTTP errors
590 if (is_wp_error($response)) {
591 return $this->error('api_request_failed', __('Failed to fetch attachments from API.', 'templately'), 'get_attachments', 500, ['error_detail' => $response->get_error_message()]);
592 }
593
594 $response_code = wp_remote_retrieve_response_code($response);
595 $xml_content = wp_remote_retrieve_body($response);
596
597 if ($response_code !== 200) {
598 // check if $xml_content contains valid json
599 // ex. '{"status":"error","message":"Attachment XML file not found in pack archive."}'
600 $error_data = @json_decode($xml_content, true);
601 if(is_array($error_data) && isset($error_data['status']) && $error_data['status'] === 'error' && !empty($error_data['message'])){
602 return $this->error('api_http_error', $error_data['message'], 'get_attachments', $response_code);
603 }
604 return $this->error('api_http_error', sprintf(__('API returned HTTP %d error.', 'templately'), $response_code), 'get_attachments', $response_code);
605 }
606
607 // Validate we have XML content
608 if (empty($xml_content)) {
609 return $this->error('no_xml_content', __('No XML content found in API response.', 'templately'), 'get_attachments', 404);
610 }
611
612 // Parse the XML content from API response
613 $parsed_data = $this->parse_xml_content($xml_content);
614
615 if (is_wp_error($parsed_data)) {
616 return $this->error('xml_parse_error', __('Failed to parse XML content.', 'templately'), 'get_attachments', 500, ['error_detail' => $parsed_data->get_error_message()]);
617 }
618
619 // Extract attachments from parsed data
620 $attachments = $this->extract_attachments_from_parsed_data($parsed_data);
621
622 return [
623 'status' => 'success',
624 'data' => $attachments,
625 'message' => sprintf(__('Found %d attachments.', 'templately'), count($attachments)),
626 ];
627
628 } catch (Exception $e) {
629 return $this->error('exception', __('An unexpected error occurred while fetching attachments.', 'templately'), 'get_attachments', 500, ['error_detail' => $e->getMessage()]);
630 }
631 }
632
633
634
635 /**
636 * Parse XML content string using WXR Parser
637 *
638 * @param string $xml_content XML content string
639 * @return array|\WP_Error Parsed data or error
640 */
641 private function parse_xml_content($xml_content) {
642 // Ensure WordPress filesystem functions are available
643 if (!function_exists('wp_tempnam')) {
644 require_once(ABSPATH . 'wp-admin/includes/file.php');
645 }
646
647 // Create a temporary file to store XML content
648 $temp_file = wp_tempnam('templately_attachments');
649 if (!$temp_file) {
650 return new WP_Error('temp_file_failed', __('Failed to create temporary file.', 'templately'));
651 }
652
653 // Write XML content to temporary file
654 $bytes_written = file_put_contents($temp_file, $xml_content);
655 if ($bytes_written === false) {
656 unlink($temp_file);
657 return new WP_Error('write_failed', __('Failed to write XML content to temporary file.', 'templately'));
658 }
659
660 try {
661 // Initialize WXR Parser
662 $parser = new WXR_Parser();
663
664 // Parse the temporary XML file
665 $parsed_data = $parser->parse($temp_file);
666
667 // Clean up temporary file
668 unlink($temp_file);
669
670 return $parsed_data;
671
672 } catch (Exception $e) {
673 // Clean up temporary file on exception
674 if (file_exists($temp_file)) {
675 unlink($temp_file);
676 }
677 return new WP_Error('parse_exception', $e->getMessage());
678 }
679 }
680
681 /**
682 * Extract attachments from parsed WXR data
683 *
684 * @param array $parsed_data Parsed WXR data
685 * @return array Array of attachment data
686 */
687 private function extract_attachments_from_parsed_data($parsed_data) {
688 $attachments = [];
689
690 if (isset($parsed_data['posts']) && is_array($parsed_data['posts'])) {
691 foreach ($parsed_data['posts'] as $post) {
692 // Check if this is an attachment
693 if (isset($post['post_type']) && $post['post_type'] === 'attachment') {
694 $attachment = [
695 'id' => isset($post['post_id']) ? (int) $post['post_id'] : 0,
696 'url' => isset($post['attachment_url']) ? (string) $post['attachment_url'] : '',
697 'title' => isset($post['post_title']) ? (string) $post['post_title'] : '',
698 'type' => isset($post['attachment_type']) ? (string) $post['attachment_type'] : '',
699 ];
700
701 // Extract metadata including dimensions and medium URL
702 $metadata = $this->extract_medium_size_url($post, $attachment['url']);
703
704 // Filter out small images (width or height <= 150px) to ignore small icons
705 if ($metadata && isset($metadata['width']) && isset($metadata['height'])) {
706 if ($metadata['width'] < 150 || $metadata['height'] < 150) {
707 continue; // Skip small images/icons
708 }
709
710 // Add dimensions to attachment data
711 $attachment['width'] = $metadata['width'];
712 $attachment['height'] = $metadata['height'];
713
714 // Add medium URL if available
715 if (isset($metadata['medium_url'])) {
716 $attachment['medium_url'] = $metadata['medium_url'];
717 }
718 } else {
719 // Skip attachments without metadata or dimensions
720 continue;
721 }
722
723 // Only add if we have the required data
724 if ($attachment['id'] && $attachment['url'] && $attachment['title']) {
725 $attachments[] = $attachment;
726 }
727 }
728 }
729 }
730
731 return $attachments;
732 }
733
734 /**
735 * Extract medium size URL from attachment metadata and get image dimensions
736 *
737 * @param array $post Post data from WXR parser
738 * @param string $original_url Original attachment URL
739 * @return array|null Array with medium_url and dimensions if found, null otherwise
740 */
741 private function extract_medium_size_url($post, $original_url) {
742 if (!isset($post['postmeta']) || !is_array($post['postmeta'])) {
743 return null;
744 }
745
746 foreach ($post['postmeta'] as $meta) {
747 if (!isset($meta['key']) || !isset($meta['value'])) {
748 continue;
749 }
750
751 // Only check _wp_attachment_metadata
752 if ($meta['key'] === '_wp_attachment_metadata') {
753 $attachment_metadata = @unserialize($meta['value']);
754 if (is_array($attachment_metadata)) {
755 $result = [];
756
757 // Get original image dimensions
758 $width = isset($attachment_metadata['width']) ? (int) $attachment_metadata['width'] : 0;
759 $height = isset($attachment_metadata['height']) ? (int) $attachment_metadata['height'] : 0;
760
761 $result['width'] = $width;
762 $result['height'] = $height;
763
764 // Check if medium size exists
765 if (isset($attachment_metadata['sizes']['medium']['file'])) {
766 // Construct medium URL from original URL and medium filename
767 $medium_filename = $attachment_metadata['sizes']['medium']['file'];
768 $original_path = dirname(parse_url($original_url, PHP_URL_PATH));
769 $base_url = str_replace(parse_url($original_url, PHP_URL_PATH), '', $original_url);
770 $result['medium_url'] = $base_url . $original_path . '/' . $medium_filename;
771 }
772
773 return $result;
774 }
775 }
776 }
777
778 return null;
779 }
780
781
782
783 /**
784 * Search images endpoint
785 *
786 * @param WP_REST_Request $request
787 * @return WP_REST_Response|WP_Error
788 */
789 public function search_images(WP_REST_Request $request) {
790 // Get and sanitize parameters
791 $query = $this->get_param('query', '');
792 $orientation = $this->get_param('orientation', 'all');
793 $size = $this->get_param('size', 'medium');
794 $color = $this->get_param('color', '');
795 $page = $this->get_param('page', 1, 'absint');
796 $per_page = $this->get_param('per_page', 20, 'absint');
797
798 // Validate required query parameter
799 if (empty($query)) {
800 return $this->error(
801 'missing_query',
802 __('Search query is required.', 'templately'),
803 'search_images',
804 400
805 );
806 }
807
808 // Prepare API request parameters
809 $api_params = [
810 'query' => urlencode($query),
811 'page' => $page,
812 'per_page' => $per_page,
813 ];
814
815 // Add optional parameters if provided
816 if ($orientation !== 'all') {
817 $api_params['orientation'] = $orientation;
818 }
819
820 if (!empty($size)) {
821 $api_params['size'] = $size;
822 }
823
824 if (!empty($color)) {
825 $api_params['color'] = $color;
826 }
827
828 // Make API request to external image service
829 $extra_headers = [
830 'Content-Type' => 'application/json',
831 ];
832
833 $response = Helper::make_api_get_request('v2/images', $api_params, $extra_headers, 30);
834
835 // Handle API response errors
836 if (is_wp_error($response)) {
837 return $this->error(
838 'api_request_failed',
839 __('Failed to fetch images from external service.', 'templately'),
840 'search_images',
841 500
842 );
843 }
844
845 $response_code = wp_remote_retrieve_response_code($response);
846 $response_body = wp_remote_retrieve_body($response);
847
848 if ($response_code !== 200) {
849 return $this->error(
850 'api_response_error',
851 sprintf(__('External API returned error code: %d', 'templately'), $response_code),
852 'search_images',
853 $response_code
854 );
855 }
856
857 // Parse and validate response
858 $data = json_decode($response_body, true);
859 if (json_last_error() !== JSON_ERROR_NONE) {
860 return $this->error(
861 'invalid_response',
862 __('Invalid response from external service.', 'templately'),
863 'search_images',
864 500
865 );
866 }
867
868 // Check if the response has the expected structure and success status
869 if (!isset($data['status']) || $data['status'] !== 'success') {
870 return $this->error(
871 'api_response_error',
872 __('External API returned an error status.', 'templately'),
873 'search_images',
874 500
875 );
876 }
877
878 // Extract nested data from the response
879 $response_data = $data['data'] ?? [];
880 $images = $response_data['images'] ?? [];
881 $total_results = $response_data['total_results'] ?? 0;
882 $current_page = $response_data['page'] ?? $page;
883 $per_page_count = $response_data['per_page'] ?? $per_page;
884
885 // Return successful response with properly mapped data
886 return $this->success([
887 'images' => $images,
888 'total' => $total_results,
889 'page' => $current_page,
890 'per_page' => $per_page_count,
891 'total_pages' => $total_results > 0 ? ceil($total_results / $per_page_count) : 0,
892 ]);
893 }
894
895
896
897 /**
898 * Generate tagline using AI
899 *
900 * @return array|WP_Error
901 */
902 public function generate_tagline() {
903 // Get parameters
904 $prompt = $this->get_param('prompt');
905 $requested_platform = $this->get_param('requested_platform', 'templately');
906
907 // Validate required parameters
908 if (empty($prompt)) {
909 return $this->error(
910 'missing_prompt',
911 __('Prompt is required for tagline generation.', 'templately'),
912 'generate_tagline',
913 400
914 );
915 }
916
917 // Prepare request body
918 $body_data = [
919 'prompt' => $prompt,
920 ];
921
922 // Make API request
923 $extra_headers = [
924 'Content-Type' => 'application/json',
925 'x-templately-requested-platform' => $requested_platform,
926 ];
927
928 $response = Helper::make_api_post_request('v2/generate-tagline', $body_data, $extra_headers, 30);
929
930 // Handle API response errors
931 if (is_wp_error($response)) {
932 return $this->error(
933 'api_request_failed',
934 __('Failed to generate tagline.', 'templately'),
935 'generate_tagline',
936 500,
937 ['error_detail' => $response->get_error_message()]
938 );
939 }
940
941 $response_code = wp_remote_retrieve_response_code($response);
942 $response_body = wp_remote_retrieve_body($response);
943
944 if ($response_code !== 200) {
945 // Try to parse the response body as JSON to get specific error details
946 $data = json_decode($response_body, true);
947
948 // If valid JSON, extract error message and return with proper status code
949 if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
950 $error_message = isset($data['message']) ? $data['message'] : __('Something went wrong. Please try again or contact support.', 'templately');
951 return $this->error(
952 'api_response_error',
953 $error_message,
954 'generate_tagline',
955 $response_code
956 );
957 }
958
959 // Otherwise, return generic error
960 return $this->error(
961 'api_response_error',
962 __('Something went wrong. Please try again or contact support.', 'templately'),
963 'generate_tagline',
964 $response_code
965 );
966 }
967
968 // Parse and validate response
969 $data = json_decode($response_body, true);
970 if (json_last_error() !== JSON_ERROR_NONE) {
971 return $this->error(
972 'invalid_response',
973 __('Invalid response from API.', 'templately'),
974 'generate_tagline',
975 500
976 );
977 }
978
979 // Check if the response has the expected structure
980 if (!isset($data['status'])) {
981 return $this->error(
982 'api_response_error',
983 __('API returned an unexpected response.', 'templately'),
984 'generate_tagline',
985 500
986 );
987 }
988
989 // Return the response as-is
990 return $data;
991 }
992
993 /**
994 * Fetch a chatbot conversation by ID from the external Templately chatbot API.
995 *
996 * Used to resume a conversation that began on templately.dev when the user
997 * is redirected into the plugin with ?process=ai&chat={uuid}.
998 *
999 * @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
1000 */
1001 public function get_chatbot_conversation() {
1002 $chat = $this->get_param('chat');
1003
1004 if (empty($chat)) {
1005 return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-conversation', 400);
1006 }
1007
1008 $extra_headers = [
1009 'Accept' => 'application/json',
1010 ];
1011 $response = Helper::make_api_get_request("v2/chatbot/conversation/{$chat}", [], $extra_headers, 30);
1012
1013 if (is_wp_error($response)) {
1014 return $this->error('request_failed', __('Failed to fetch conversation.', 'templately'), 'ai-content/chatbot-conversation', 500, ['error_detail' => $response->get_error_message()]);
1015 }
1016
1017 $response_code = wp_remote_retrieve_response_code($response);
1018 $body = wp_remote_retrieve_body($response);
1019 $data = json_decode($body, true);
1020
1021 if ($response_code !== 200) {
1022 $message = (is_array($data) && !empty($data['message'])) ? $data['message'] : sprintf(__('API returned HTTP %d error.', 'templately'), $response_code);
1023 return $this->error('api_http_error', $message, 'ai-content/chatbot-conversation', $response_code);
1024 }
1025
1026 if (!is_array($data) || !isset($data['status'])) {
1027 return $this->error('invalid_response', __('Invalid response.', 'templately'), 'ai-content/chatbot-conversation', 500);
1028 }
1029
1030 return $data;
1031 }
1032
1033 /**
1034 * Fetch the server-side generated content for a chatbot conversation (Phase 2).
1035 *
1036 * In Phase 2 the AI content is generated on the backend. This proxy mirrors
1037 * {@see get_chatbot_conversation()} and returns the already-generated page
1038 * content, customization data and signed logo URL so the plugin can run a
1039 * thin import without triggering generation or the customizer locally.
1040 *
1041 * @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
1042 */
1043 public function get_chatbot_generated() {
1044 $chat = $this->get_param('chat');
1045
1046 if (empty($chat)) {
1047 return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-generated', 400);
1048 }
1049
1050 $extra_headers = [
1051 'Accept' => 'application/json',
1052 ];
1053 $response = Helper::make_api_get_request("v2/chatbot/generated/{$chat}", [], $extra_headers, 30);
1054
1055 if (is_wp_error($response)) {
1056 return $this->error('request_failed', __('Failed to fetch generated content.', 'templately'), 'ai-content/chatbot-generated', 500, ['error_detail' => $response->get_error_message()]);
1057 }
1058
1059 $response_code = wp_remote_retrieve_response_code($response);
1060 $body = wp_remote_retrieve_body($response);
1061 $data = json_decode($body, true);
1062
1063 if ($response_code !== 200) {
1064 $message = (is_array($data) && !empty($data['message'])) ? $data['message'] : sprintf(__('API returned HTTP %d error.', 'templately'), $response_code);
1065 return $this->error('api_http_error', $message, 'ai-content/chatbot-generated', $response_code);
1066 }
1067
1068 if (!is_array($data) || !isset($data['status'])) {
1069 return $this->error('invalid_response', __('Invalid response.', 'templately'), 'ai-content/chatbot-generated', 500);
1070 }
1071
1072 // The direct-import handoff reads this endpoint and then immediately calls
1073 // chatbot-import-prepare, which pulls the very same (large) bundle off GCP
1074 // seconds later. Park it so prepare can reuse it instead of paying for a
1075 // second identical transfer.
1076 Database::set_transient(self::GENERATED_CACHE_KEY . $chat, $data, self::GENERATED_CACHE_TTL);
1077
1078 return $data;
1079 }
1080
1081 /**
1082 * Persist edited detected-info back to the chatbot conversation (Phase 2).
1083 *
1084 * Mirrors {@see get_chatbot_conversation()} / {@see get_chatbot_generated()}
1085 * but forwards a POST. When the user edits the detected-info card in the
1086 * sidebar, the plugin proxies the corrected values to the backend so a later
1087 * replay reflects them. The backend route is gated by the X-Templately-Apikey
1088 * header, so it is supplied explicitly here.
1089 *
1090 * Expected JSON body: { chat, detected_info: { ...fields } }
1091 *
1092 * @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
1093 */
1094 public function update_chatbot_detected_info() {
1095 $chat = $this->get_param('chat');
1096 $detected_info = $this->get_param('detected_info', [], null);
1097
1098 if (empty($chat)) {
1099 return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-detected-info', 400);
1100 }
1101
1102 // detected_info may arrive as a JSON string when sent via FormData.
1103 if (is_string($detected_info)) {
1104 $decoded = json_decode($detected_info, true);
1105 $detected_info = is_array($decoded) ? $decoded : [];
1106 }
1107 if (!is_array($detected_info)) {
1108 $detected_info = [];
1109 }
1110
1111 $extra_headers = [
1112 'Accept' => 'application/json',
1113 'X-Templately-Apikey' => $this->api_key,
1114 ];
1115 $response = Helper::make_api_post_request("v2/chatbot/conversation/{$chat}/detected-info", ['detected_info' => $detected_info], $extra_headers, 30);
1116
1117 if (is_wp_error($response)) {
1118 return $this->error('request_failed', __('Failed to update detected info.', 'templately'), 'ai-content/chatbot-detected-info', 500, ['error_detail' => $response->get_error_message()]);
1119 }
1120
1121 $response_code = wp_remote_retrieve_response_code($response);
1122 $body = wp_remote_retrieve_body($response);
1123 $data = json_decode($body, true);
1124
1125 if ($response_code !== 200) {
1126 $message = (is_array($data) && !empty($data['message'])) ? $data['message'] : sprintf(__('API returned HTTP %d error.', 'templately'), $response_code);
1127 return $this->error('api_http_error', $message, 'ai-content/chatbot-detected-info', $response_code);
1128 }
1129
1130 if (!is_array($data) || !isset($data['status'])) {
1131 return $this->error('invalid_response', __('Invalid response.', 'templately'), 'ai-content/chatbot-detected-info', 500);
1132 }
1133
1134 return $data;
1135 }
1136
1137 /**
1138 * Report a completed import back to templately.dev (Phase 2 import-once gate).
1139 *
1140 * Once the plugin finishes importing the generated content for a conversation,
1141 * it calls this so the backend flips the conversation status to `imported`.
1142 * Subsequent pulls then return `already_imported = true`, and the web/plugin
1143 * UIs refuse a second import.
1144 *
1145 * @return array|\WP_Error
1146 */
1147 public function mark_chatbot_imported() {
1148 $chat = $this->get_param('chat');
1149
1150 if (empty($chat)) {
1151 return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-mark-imported', 400);
1152 }
1153
1154 $extra_headers = [
1155 'Accept' => 'application/json',
1156 'X-Templately-Apikey' => $this->api_key,
1157 ];
1158 $response = Helper::make_api_post_request("v2/chatbot/conversation/{$chat}/imported", [], $extra_headers, 30);
1159
1160 if (is_wp_error($response)) {
1161 return $this->error('request_failed', __('Failed to record import.', 'templately'), 'ai-content/chatbot-mark-imported', 500, ['error_detail' => $response->get_error_message()]);
1162 }
1163
1164 $response_code = wp_remote_retrieve_response_code($response);
1165 $body = wp_remote_retrieve_body($response);
1166 $data = json_decode($body, true);
1167
1168 if ($response_code !== 200) {
1169 $message = (is_array($data) && !empty($data['message'])) ? $data['message'] : sprintf(__('API returned HTTP %d error.', 'templately'), $response_code);
1170 return $this->error('api_http_error', $message, 'ai-content/chatbot-mark-imported', $response_code);
1171 }
1172
1173 if (!is_array($data) || !isset($data['status'])) {
1174 return $this->error('invalid_response', __('Invalid response.', 'templately'), 'ai-content/chatbot-mark-imported', 500);
1175 }
1176
1177 return $data;
1178 }
1179
1180 /**
1181 * Thin importer prepare step (Phase 2).
1182 *
1183 * Given a chat uuid and a session that has already been created and had its
1184 * pack downloaded (via the existing templately_pack_create_session_and_download
1185 * AJAX flow), this:
1186 * 1. Registers AI process data (including `chat_id`) so ai_get_json()/
1187 * validation keep working AND so the Finalizer's ChatAIContentProvider
1188 * can claim this process.
1189 * 2. Fetches the backend-generated page content for the conversation.
1190 * 3. Writes whatever pages are ALREADY generated to the same .ai.json
1191 * location the legacy flow uses (via AIUtils::save_template_to_file).
1192 * 4. Downloads the signed logo URL into the WP media library (Utils::upload_logo).
1193 *
1194 * This endpoint NEVER waits for generation to complete. Pages still being
1195 * generated are reported in `missing` and are pulled on demand — and waited
1196 * for — by the Finalizer via AIContentResolver. Previously this held the
1197 * client in a 3s poll loop until every page was ready, which delayed the
1198 * start of the import by minutes for no benefit.
1199 *
1200 * It returns the resolved customization data, logo attachment and the
1201 * process_id so the React app can build the settings FormData and run the
1202 * existing import. No generation or local customizer is involved.
1203 *
1204 * Expected JSON body: { chat, session_id, ai_page_ids: { 'content/page': [...], templates: [...] } }
1205 *
1206 * @return array|\WP_Error
1207 */
1208 /**
1209 * Does a `v2/chatbot/generated` bundle already carry every expected page?
1210 *
1211 * A page counts as present when it is in `templates` (string or int key, the
1212 * upstream is inconsistent) or listed in `skipped_pages` — a skipped page is
1213 * never coming, so waiting on it would hang the poll until it timed out.
1214 *
1215 * @param array $data Decoded `{ status, data }` bundle.
1216 * @param array $expected_ids Flattened page ids, as strings.
1217 * @return bool
1218 */
1219 private function is_generated_bundle_complete($data, $expected_ids) {
1220 $generated = isset($data['data']) && is_array($data['data']) ? $data['data'] : [];
1221
1222 // Never reuse a bundle the user is no longer allowed to import.
1223 if (isset($generated['can_import']) && ! $generated['can_import']) {
1224 return false;
1225 }
1226
1227 $templates = isset($generated['templates']) && is_array($generated['templates']) ? $generated['templates'] : [];
1228 $skipped = isset($generated['skipped_pages']) && is_array($generated['skipped_pages']) ? array_map('strval', $generated['skipped_pages']) : [];
1229
1230 if (empty($templates) && empty($skipped)) {
1231 return false;
1232 }
1233
1234 foreach ($expected_ids as $id) {
1235 if (array_key_exists($id, $templates) || array_key_exists((int) $id, $templates) || in_array($id, $skipped, true)) {
1236 continue;
1237 }
1238 return false;
1239 }
1240
1241 return true;
1242 }
1243
1244 /**
1245 * Resolve the conversation answers to store on a chat-driven process.
1246 *
1247 * Prefers what the client sent (the sidebar holds the detected info the user
1248 * may have just edited), then what was already stored for this process (so a
1249 * repeat call costs nothing), and only then pulls the conversation from the
1250 * cloud — which is the path the `?process=import` landing takes, since it
1251 * never opens the sidebar and so has no answers to send.
1252 *
1253 * A failure here must never break the import: it returns an empty array and
1254 * the process is stored exactly as before.
1255 *
1256 * @param string $chat Conversation uuid.
1257 * @param mixed $detected_info Raw `detected_info` sent by the client.
1258 * @param array $ai_process_data All stored process data.
1259 * @param string $process_id This process id.
1260 * @return array<string,string> Map of conversation step key => value.
1261 */
1262 private function resolve_chat_conversation_fields($chat, $detected_info, $ai_process_data, $process_id) {
1263 $mapped = AIUtils::map_chat_detected_info($detected_info);
1264 if (!empty($mapped)) {
1265 return $mapped;
1266 }
1267
1268 // Already resolved on an earlier call for this process — reuse it.
1269 if (isset($ai_process_data[$process_id]) && AIUtils::has_conversation_data($ai_process_data[$process_id])) {
1270 return AIUtils::map_chat_detected_info(
1271 array_intersect_key($ai_process_data[$process_id], array_flip(AIUtils::CONVERSATION_FIELDS))
1272 );
1273 }
1274
1275 $response = Helper::make_api_get_request("v2/chatbot/conversation/{$chat}", [], ['Accept' => 'application/json'], 30);
1276 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1277 Helper::log(sprintf('chatbot_import_prepare[%s] conversation fetch failed — process stored without answers', $chat), 'ai-import', 'warning');
1278 return [];
1279 }
1280
1281 $data = json_decode(wp_remote_retrieve_body($response), true);
1282 if (!is_array($data) || empty($data['data']['detected_info'])) {
1283 return [];
1284 }
1285
1286 return AIUtils::map_chat_detected_info($data['data']['detected_info']);
1287 }
1288
1289 public function chatbot_import_prepare() {
1290 add_filter('wp_redirect', '__return_false', 999);
1291 set_time_limit(3 * MINUTE_IN_SECONDS);
1292
1293 $handler_started = microtime(true);
1294
1295 $chat = $this->get_param('chat');
1296 $session_id = $this->get_param('session_id');
1297 $ai_page_ids = $this->get_param('ai_page_ids', [], null);
1298 // Conversation context, so reopening "Build with AI" later can resume this
1299 // app-end session instead of starting from scratch. `detected_info` is
1300 // sanitized field-by-field in AIUtils::map_chat_detected_info().
1301 $pack_id = $this->get_param('pack_id', 0, 'absint');
1302 $platform = $this->get_param('platform');
1303 $detected_info = $this->get_param('detected_info', [], null);
1304
1305 if (empty($chat)) {
1306 return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-import-prepare', 400);
1307 }
1308
1309 if (empty($session_id)) {
1310 return $this->error('invalid_session_id', __('Invalid session ID.', 'templately'), 'ai-content/chatbot-import-prepare', 400);
1311 }
1312
1313 // Security: sanitize the session id before it is used to build file paths.
1314 $session_id = AIUtils::sanitize_path_component($session_id, 'session_id');
1315 if (is_wp_error($session_id)) {
1316 return $this->error('invalid_session_id', $session_id->get_error_message(), 'ai-content/chatbot-import-prepare', 400);
1317 }
1318
1319 // ai_page_ids may arrive as a JSON string when sent via FormData, and with
1320 // scalar / comma-separated group values — normalize to the canonical
1321 // `type/sub_type => ['id',...]` shape before anything indexes into it.
1322 $ai_page_ids = AIUtils::normalize_ai_page_ids($ai_page_ids);
1323 if (empty($ai_page_ids)) {
1324 return $this->error('invalid_ai_page_ids', __('Invalid AI page IDs.', 'templately'), 'ai-content/chatbot-import-prepare', 400);
1325 }
1326
1327 // Expected page ids (flattened) — the client may redirect to customization
1328 // as soon as the home/header/footer are ready, so by import time some pages
1329 // can still be generating. We re-pull until every expected page is present
1330 // (bounded wait), then proceed; anything still missing falls back to the
1331 // pack's default content.
1332 $expected_ids = AIUtils::flatten_ai_page_ids($ai_page_ids);
1333
1334 $extra_headers = ['Accept' => 'application/json'];
1335 $cache_key = self::GENERATED_CACHE_KEY . $chat;
1336
1337 // This endpoint NEVER waits for completeness. The import starts as soon as
1338 // the session exists; whatever pages are already generated are written
1339 // here as a warm start, and any page still generating is pulled on demand
1340 // by the Finalizer (Core/Importer/Utils/AIContentResolver +
1341 // Providers/ChatAIContentProvider).
1342 //
1343 // Reuse the bundle chatbot-generated just parked, but ONLY when it already
1344 // holds every expected page — a complete bundle cannot become less
1345 // complete, whereas an incomplete one has to be re-pulled to pick up the
1346 // pages that have since finished. On the common handoff (generation
1347 // finished long before the user landed here) this removes an entire
1348 // duplicate transfer of every page's block JSON.
1349 $pull_started = microtime(true);
1350 $pull_duration = 0;
1351 $data = Database::get_transient($cache_key);
1352 $from_cache = is_array($data) && $this->is_generated_bundle_complete($data, $expected_ids);
1353
1354 if (! $from_cache) {
1355 // Single pull — no server-side sleep/retry. The user can reach import as
1356 // soon as the key pages (home/header/footer) are ready while the rest are
1357 // still generating; rather than hold the request open until everything is
1358 // done (which tripped a gateway 504), we return a non-fatal `pending`
1359 // status and let the client poll (JS-side pull).
1360 $response = Helper::make_api_get_request("v2/chatbot/generated/{$chat}", [], $extra_headers, 2 * MINUTE_IN_SECONDS);
1361 $pull_duration = microtime(true) - $pull_started;
1362
1363 if (is_wp_error($response)) {
1364 Helper::log(sprintf('chatbot_import_prepare[%s] pull failed after %.2fs: %s', $chat, $pull_duration, $response->get_error_message()), 'ai-import', 'error');
1365 return $this->error('request_failed', __('Failed to fetch generated content.', 'templately'), 'ai-content/chatbot-import-prepare', 500, ['error_detail' => $response->get_error_message()]);
1366 }
1367
1368 $response_code = wp_remote_retrieve_response_code($response);
1369 $data = json_decode(wp_remote_retrieve_body($response), true);
1370
1371 if ($response_code !== 200 || !is_array($data) || !isset($data['status'])) {
1372 $message = (is_array($data) && !empty($data['message'])) ? $data['message'] : sprintf(__('API returned HTTP %d error.', 'templately'), $response_code);
1373 Helper::log(sprintf('chatbot_import_prepare[%s] pull HTTP %d after %.2fs', $chat, $response_code, $pull_duration), 'ai-import', 'error');
1374 return $this->error('api_http_error', $message, 'ai-content/chatbot-import-prepare', $response_code ?: 500);
1375 }
1376
1377 // Park a complete bundle for the credits re-read on the success screen.
1378 if ($this->is_generated_bundle_complete($data, $expected_ids)) {
1379 Database::set_transient($cache_key, $data, self::GENERATED_CACHE_TTL);
1380 }
1381 } else {
1382 Helper::log(sprintf('chatbot_import_prepare[%s] reused cached bundle (no upstream pull)', $chat), 'ai-import', 'info');
1383 }
1384
1385 $generated = isset($data['data']) && is_array($data['data']) ? $data['data'] : [];
1386
1387 // Access gate: the backend blocks a free user past their 7-day window.
1388 if (isset($generated['can_import']) && ! $generated['can_import']) {
1389 return $this->error('access_expired', __('Your free access to this generated site has ended. Upgrade your plan or purchase this template to import it.', 'templately'), 'ai-content/chatbot-import-prepare', 403);
1390 }
1391
1392 $templates = isset($generated['templates']) && is_array($generated['templates']) ? $generated['templates'] : [];
1393
1394 // Pages the backend skipped (empty source JSON) or failed to generate.
1395 // These will never appear in `templates`, so they must not be treated
1396 // as "still generating" — without this, one skipped page keeps the poll
1397 // pending until it times out.
1398 $skipped_pages = isset($generated['skipped_pages']) && is_array($generated['skipped_pages']) ? array_map('strval', $generated['skipped_pages']) : [];
1399 $skipped_expected = array_values(array_intersect($expected_ids, $skipped_pages));
1400
1401 // Which expected pages are still missing from the bundle?
1402 $missing = array_values(array_filter($expected_ids, function ($id) use ($templates, $skipped_pages) {
1403 return !array_key_exists($id, $templates) && !array_key_exists((int) $id, $templates) && !in_array($id, $skipped_pages, true);
1404 }));
1405
1406 $ready = array_values(array_diff($expected_ids, $missing));
1407 Helper::log(sprintf('chatbot_import_prepare[%s] warm start: ready=%d/%d missing=%d skipped=%d pull=%.2fs', $chat, count($ready), count($expected_ids), count($missing), count($skipped_expected), $pull_duration), 'ai-import', 'info');
1408
1409
1410 // Derive a process_id for this chat-driven import and register process data
1411 // so the existing validation/ai_get_json paths keep functioning.
1412 $process_id = 'chat-' . $session_id;
1413 $user = $this->utils('options')->get('user');
1414
1415 $ai_process_data = AIUtils::get_ai_process_data();
1416 $record = [
1417 'process_id' => $process_id,
1418 'session_id' => $session_id,
1419 'ai_page_ids' => $ai_page_ids,
1420 'api_key' => $this->api_key,
1421 'user_id' => isset($user['id']) ? $user['id'] : null,
1422 'chat_id' => $chat,
1423 ];
1424
1425 if (!empty($pack_id)) {
1426 $record['pack_id'] = $pack_id;
1427 }
1428 if (!empty($platform)) {
1429 $record['platform'] = $platform;
1430 }
1431
1432 // Persist the app-end answers exactly as an in-plugin conversation stores
1433 // them, so reopening "Build with AI" resumes this session instead of
1434 // starting over. Without this the record holds no answers at all and the
1435 // sidebar has nothing to restore.
1436 $conversation = AIUtils::build_conversation_fields(
1437 $this->resolve_chat_conversation_fields($chat, $detected_info, $ai_process_data, $process_id)
1438 );
1439 if (!empty($conversation)) {
1440 $record = array_merge($record, $conversation);
1441 }
1442
1443 $ai_process_data[$process_id] = $record;
1444 AIUtils::update_ai_process_data($ai_process_data);
1445
1446 // Persist each generated page to its .ai.json location for the import runners.
1447 //
1448 // Every page present in THIS pull is written immediately, even when others
1449 // are still generating. Holding the writes back until the whole set was
1450 // ready meant a single slow page threw away the full bundle on every poll
1451 // — dozens of multi-hundred-KB pulls (all of `templates`, straight off GCP)
1452 // discarded to save nothing. Writing as we go also lets the Finalizer
1453 // runner finalize the pages that ARE ready instead of blocking on all of
1454 // them. Already-written pages are skipped, so a re-poll is cheap.
1455 $save_started = microtime(true);
1456 $saved_count = 0;
1457 $errors = [];
1458 $processed_pages = get_option('templately_ai_processed_pages', []);
1459 $already_saved = isset($processed_pages[$process_id]['pages']) ? $processed_pages[$process_id]['pages'] : [];
1460 foreach ($templates as $content_id => $template) {
1461 if (empty($template)) {
1462 continue;
1463 }
1464
1465 if (array_key_exists((string) $content_id, $already_saved)) {
1466 $saved_count++;
1467 continue;
1468 }
1469
1470 // The runners read JSON strings; normalize arrays/objects to a string.
1471 $template_payload = is_string($template) ? $template : wp_json_encode($template);
1472
1473 $result = AIUtils::save_template_to_file(
1474 $process_id,
1475 $session_id,
1476 $content_id,
1477 $template_payload,
1478 $ai_page_ids,
1479 false
1480 );
1481
1482 if (is_wp_error($result)) {
1483 $errors[$content_id] = $result->get_error_message();
1484 continue;
1485 }
1486 if (isset($result['status']) && $result['status'] === 'success') {
1487 $saved_count++;
1488 } else {
1489 $errors[$content_id] = isset($result['message']) ? $result['message'] : 'unknown';
1490 }
1491 }
1492
1493 // Write each backend-skipped page as an explicit `{"isSkipped": true}`
1494 // marker (same shape the legacy per-page callback wrote) so the import
1495 // runners fall back to the pack's default content instead of treating
1496 // the page as missing.
1497 $skipped_saved = [];
1498 foreach ($skipped_expected as $skipped_id) {
1499 if (array_key_exists($skipped_id, $templates) || array_key_exists((int) $skipped_id, $templates)) {
1500 continue;
1501 }
1502
1503 if (array_key_exists((string) $skipped_id, $already_saved)) {
1504 $skipped_saved[] = $skipped_id;
1505 continue;
1506 }
1507
1508 $result = AIUtils::save_template_to_file(
1509 $process_id,
1510 $session_id,
1511 $skipped_id,
1512 '',
1513 $ai_page_ids,
1514 true
1515 );
1516
1517 if (is_wp_error($result)) {
1518 $errors[$skipped_id] = $result->get_error_message();
1519 continue;
1520 }
1521 if (isset($result['status']) && $result['status'] === 'success') {
1522 $skipped_saved[] = $skipped_id;
1523 } else {
1524 $errors[$skipped_id] = isset($result['message']) ? $result['message'] : 'unknown';
1525 }
1526 }
1527
1528 $save_duration = microtime(true) - $save_started;
1529 Helper::log(sprintf('chatbot_import_prepare[%s] saved %d/%d pages in %.2fs (skipped=%d, errors=%d)', $chat, $saved_count, count($templates), $save_duration, count($skipped_saved), count($errors)), 'ai-import', 'info');
1530
1531 // NOTE: there is deliberately NO `pending` return here any more. Pages that
1532 // are still generating come back in `missing` and are pulled on demand —
1533 // and waited for — by the Finalizer. Returning `pending` made the client
1534 // poll for minutes before the import could even start.
1535 //
1536 // NOT an error when nothing was saved: with the wait deferred to the
1537 // Finalizer it is legitimate for zero pages to be ready at import start.
1538 // Only a genuine write failure (something was ready but every save
1539 // errored) is fatal.
1540 if ($saved_count === 0 && empty($skipped_saved) && !empty($errors)) {
1541 return $this->error('save_failed', __('Failed to save generated content.', 'templately'), 'ai-content/chatbot-import-prepare', 500, ['errors' => $errors]);
1542 }
1543
1544 Helper::log(sprintf('chatbot_import_prepare[%s] returning: pages=%d missing=%d pull=%.2fs', $chat, count($templates), count($missing), $pull_duration), 'ai-import', 'info');
1545
1546 // Import the logo into the media library and map it into the customization.
1547 $customization = isset($generated['customization_data']) && is_array($generated['customization_data']) ? $generated['customization_data'] : [];
1548 $logo = null;
1549 $logo_url = !empty($generated['logo_url']) ? esc_url_raw($generated['logo_url']) : '';
1550
1551 if (!empty($logo_url)) {
1552 $logo_started = microtime(true);
1553 $uploaded = Utils::upload_logo($logo_url, $session_id);
1554 Helper::log(sprintf('chatbot_import_prepare[%s] logo upload in %.2fs', $chat, microtime(true) - $logo_started), 'ai-import', 'info');
1555 if (!empty($uploaded['id'])) {
1556 $logo = [
1557 'id' => (int) $uploaded['id'],
1558 'url' => $uploaded['url'],
1559 ];
1560 } elseif (!empty($uploaded['error'])) {
1561 // Logo is non-fatal: log and continue without it.
1562 Helper::log('chatbot_import_prepare logo upload failed: ' . $uploaded['error']);
1563 }
1564 }
1565
1566 // Reflect the imported logo back into the customization payload so React
1567 // can build the settings FormData from a single source.
1568 if (!empty($logo)) {
1569 $customization['logo'] = $logo;
1570 }
1571
1572 Helper::log(sprintf('chatbot_import_prepare[%s] done in %.2fs total', $chat, microtime(true) - $handler_started), 'ai-import', 'info');
1573
1574 return [
1575 'status' => 'success',
1576 'data' => [
1577 'session_id' => $session_id,
1578 'process_id' => $process_id,
1579 'ai_page_ids' => $ai_page_ids,
1580 'saved' => $saved_count,
1581 // Pages the backend explicitly skipped — imported with the
1582 // pack's default content instead.
1583 'skipped' => $skipped_expected,
1584 // Readiness snapshot at import start. `missing` pages are NOT a
1585 // failure: the Finalizer pulls each on demand and waits for it.
1586 'expected' => $expected_ids,
1587 'ready' => $ready,
1588 'missing' => $missing,
1589 'platform' => isset($customization['platform']) ? $customization['platform'] : null,
1590 'customization_data' => $customization,
1591 'logo' => $logo,
1592 'errors' => $errors,
1593 ],
1594 ];
1595 }
1596
1597 /**
1598 * Validate API key against database
1599 * Checks if the provided API key exists for any user on the current site
1600 * Handles both single-site and multisite WordPress installations
1601 *
1602 * @param string $api_key The API key to validate
1603 * @return bool True if valid, false otherwise
1604 */
1605 private function validate_api_key_in_db($api_key) {
1606 global $wpdb;
1607
1608 $api_key = sanitize_text_field($api_key);
1609
1610 if (empty($api_key)) {
1611 return false;
1612 }
1613
1614 $meta_key = '_templately_api_key';
1615
1616 // Handle multisite: key will have site prefix in multisite
1617 if (is_multisite()) {
1618 // get_user_option() uses the format: {$wpdb->base_prefix}{$blog_id}_{$meta_key}
1619 // For current blog, we need to check with the current blog prefix
1620 $blog_id = get_current_blog_id();
1621 $meta_key = $wpdb->get_blog_prefix($blog_id) . $meta_key;
1622 }
1623
1624 // Query to check if this API key exists for any user
1625 $query = $wpdb->prepare(
1626 "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
1627 $meta_key,
1628 $api_key
1629 );
1630
1631 $user_id = $wpdb->get_var($query);
1632
1633 return !empty($user_id);
1634 }
1635 }
1636