| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* Templately AI Content Importer |
| 5 |
* |
| 6 |
* @package Templately |
| 7 |
* @since 1.0.0 |
| 8 |
*/ |
| 9 |
|
| 10 |
namespace Templately\Modules\AiFsi\REST; |
| 11 |
|
| 12 |
use Error; |
| 13 |
use Exception; |
| 14 |
use Templately\API\API; |
| 15 |
use Templately\Utils\Helper; |
| 16 |
use Templately\Utils\Response\ErrorCode; |
| 17 |
use WP_REST_Request; |
| 18 |
use WP_Error; |
| 19 |
use Templately\Modules\FullSiteImport\Utils\Utils; |
| 20 |
use Templately\Modules\FullSiteImport\Utils\AIUtils; |
| 21 |
use Templately\Modules\FullSiteImport\Utils\SessionData; |
| 22 |
use Templately\Utils\Database; |
| 23 |
use Templately\Modules\FullSiteImport\Utils\SignatureVerifier; |
| 24 |
use Templately\Modules\FullSiteImport\Parsers\WXR_Parser; |
| 25 |
|
| 26 |
class AIContent extends API { |
| 27 |
private $endpoint = 'ai-content'; |
| 28 |
private $dev_mode = false; |
| 29 |
|
| 30 |
/** |
| 31 |
* Short-lived cache of the `v2/chatbot/generated/{chat}` bundle. |
| 32 |
* |
| 33 |
* That payload is large (every generated page's block JSON, served off GCP) |
| 34 |
* and the direct-import handoff pulls it twice within seconds — once to read |
| 35 |
* the customization, once to write the pages. Only a COMPLETE bundle is ever |
| 36 |
* reused (an incomplete one has to be re-pulled to pick up new pages), and |
| 37 |
* the TTL is deliberately short so the `can_import` / already-imported gate |
| 38 |
* cannot go meaningfully stale. |
| 39 |
*/ |
| 40 |
const GENERATED_CACHE_KEY = 'chatbot_generated_'; |
| 41 |
const GENERATED_CACHE_TTL = 60; |
| 42 |
|
| 43 |
|
| 44 |
/** |
| 45 |
* AIContent constructor. |
| 46 |
* |
| 47 |
* @param string $file File path. |
| 48 |
* @param array $settings Settings. |
| 49 |
*/ |
| 50 |
public function __construct() { |
| 51 |
|
| 52 |
parent::__construct(); |
| 53 |
|
| 54 |
} |
| 55 |
|
| 56 |
public function _permission_check(WP_REST_Request $request) { |
| 57 |
$this->request = $request; |
| 58 |
$this->api_key = $this->utils('options')->get( 'api_key' ); |
| 59 |
$process_id = $this->get_param('process_id'); |
| 60 |
|
| 61 |
$_route = $request->get_route(); |
| 62 |
if ('/templately/v1/ai-content/ai-update' === $_route || '/templately/v1/ai-content/ai-update-preview' === $_route) { |
| 63 |
// Redact the API-key header before logging — a credential must NEVER |
| 64 |
// reach the log, even under WP_DEBUG_LOG (Helper::log's guard). The body |
| 65 |
// (AI request params, not a credential) stays inside that dev guard. |
| 66 |
$safe_headers = $request->get_headers(); |
| 67 |
foreach ( [ 'x_templately_apikey', 'x-templately-apikey' ] as $redact_key ) { |
| 68 |
if ( isset( $safe_headers[ $redact_key ] ) ) { |
| 69 |
$safe_headers[ $redact_key ] = [ '[redacted]' ]; |
| 70 |
} |
| 71 |
} |
| 72 |
Helper::log( [ |
| 73 |
'headers' => $safe_headers, |
| 74 |
'body' => $request->get_params(), |
| 75 |
], 'ai_update_request' ); |
| 76 |
|
| 77 |
if (empty($process_id)) { |
| 78 |
return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'calculate_credit', 400); |
| 79 |
} |
| 80 |
|
| 81 |
$header_api_key = sanitize_text_field($request->get_header('x_templately_apikey')); |
| 82 |
if (empty($header_api_key)) { |
| 83 |
$header_api_key = sanitize_text_field($request->get_header('X-Templately-Apikey')); |
| 84 |
} |
| 85 |
|
| 86 |
// Validate API key from header against database |
| 87 |
if (empty($header_api_key)) { |
| 88 |
return $this->error('missing_api_key', __('Missing API key in header.', 'templately'), 'ai-content/permission', 403); |
| 89 |
} |
| 90 |
|
| 91 |
$is_valid_key = $this->validate_api_key_in_db($header_api_key); |
| 92 |
if (!$is_valid_key) { |
| 93 |
return $this->error('invalid_api_key', __('Invalid API key provided in header.', 'templately'), 'ai-content/permission', 403); |
| 94 |
} |
| 95 |
|
| 96 |
// Verify the callback HMAC signature (034 FR-001) — log-only by default; |
| 97 |
// rejects only in enforce mode, once cloud-signed traffic is confirmed. |
| 98 |
$verified = SignatureVerifier::verify_request($request, $header_api_key, 'ai-content/permission'); |
| 99 |
if (is_wp_error($verified)) { |
| 100 |
return $verified; |
| 101 |
} |
| 102 |
|
| 103 |
// Check the AI process exists (single per-process row read; 026 seam). |
| 104 |
if (AIUtils::read_process($process_id) !== null) { |
| 105 |
return true; |
| 106 |
} |
| 107 |
|
| 108 |
return (bool) AIUtils::get_matched_session_data($process_id); |
| 109 |
} |
| 110 |
|
| 111 |
// // Allow access to attachments endpoint |
| 112 |
// if ('/templately/v1/ai-content/attachments' === $_route) { |
| 113 |
// return true; |
| 114 |
// } |
| 115 |
return parent::_permission_check($request); |
| 116 |
} |
| 117 |
|
| 118 |
|
| 119 |
public function register_routes() { |
| 120 |
$this->post($this->endpoint . '/modify-content', [$this, 'modify_content']); |
| 121 |
$this->post($this->endpoint . '/ai-update', [$this, 'ai_update']); |
| 122 |
$this->post($this->endpoint . '/ai-update-preview', [$this, 'ai_update_preview']); |
| 123 |
$this->post($this->endpoint . '/generate-tagline', [$this, 'generate_tagline']); |
| 124 |
$this->post($this->endpoint . '/generate-brand-kit', [$this, 'generate_brand_kit']); |
| 125 |
$this->get($this->endpoint . '/chatbot-conversation', [$this, 'get_chatbot_conversation'], [ |
| 126 |
'chat' => [ |
| 127 |
'required' => true, |
| 128 |
'sanitize_callback' => 'sanitize_text_field', |
| 129 |
'validate_callback' => function($param, $request, $key) { |
| 130 |
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param); |
| 131 |
}, |
| 132 |
], |
| 133 |
]); |
| 134 |
$this->get($this->endpoint . '/chatbot-conversations', [$this, 'get_chatbot_conversations'], [ |
| 135 |
'page' => [ |
| 136 |
'default' => 1, |
| 137 |
'required' => false, |
| 138 |
'sanitize_callback' => 'absint', |
| 139 |
], |
| 140 |
'per_page' => [ |
| 141 |
'default' => 10, |
| 142 |
'required' => false, |
| 143 |
'sanitize_callback' => 'absint', |
| 144 |
], |
| 145 |
]); |
| 146 |
$this->get($this->endpoint . '/chatbot-generated', [$this, 'get_chatbot_generated'], [ |
| 147 |
'chat' => [ |
| 148 |
'required' => true, |
| 149 |
'sanitize_callback' => 'sanitize_text_field', |
| 150 |
'validate_callback' => function($param, $request, $key) { |
| 151 |
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param); |
| 152 |
}, |
| 153 |
], |
| 154 |
]); |
| 155 |
$this->post($this->endpoint . '/chatbot-detected-info', [$this, 'update_chatbot_detected_info'], [ |
| 156 |
'chat' => [ |
| 157 |
'required' => true, |
| 158 |
'sanitize_callback' => 'sanitize_text_field', |
| 159 |
'validate_callback' => function($param, $request, $key) { |
| 160 |
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param); |
| 161 |
}, |
| 162 |
], |
| 163 |
]); |
| 164 |
$this->post($this->endpoint . '/chatbot-import-prepare', [$this, 'chatbot_import_prepare']); |
| 165 |
$this->post($this->endpoint . '/chatbot-mark-imported', [$this, 'mark_chatbot_imported'], [ |
| 166 |
'chat' => [ |
| 167 |
'required' => true, |
| 168 |
'sanitize_callback' => 'sanitize_text_field', |
| 169 |
'validate_callback' => function($param, $request, $key) { |
| 170 |
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param); |
| 171 |
}, |
| 172 |
], |
| 173 |
]); |
| 174 |
$this->get($this->endpoint . '/attachments', [$this, 'get_attachments'], [ |
| 175 |
'type' => [ |
| 176 |
'default' => 'pack', |
| 177 |
'required' => false, |
| 178 |
'sanitize_callback' => 'sanitize_text_field', |
| 179 |
], |
| 180 |
'id' => [ |
| 181 |
'required' => false, |
| 182 |
'sanitize_callback' => 'sanitize_text_field', |
| 183 |
], |
| 184 |
'pack_id' => [ |
| 185 |
'required' => false, |
| 186 |
'sanitize_callback' => 'sanitize_text_field', |
| 187 |
], |
| 188 |
]); |
| 189 |
$this->post($this->endpoint . '/customization', [$this, 'save_customization'], [ |
| 190 |
'process_id' => [ |
| 191 |
'required' => true, |
| 192 |
'sanitize_callback' => 'sanitize_text_field', |
| 193 |
], |
| 194 |
// A free-form design blob (title, tagline, colours, typography, logo + |
| 195 |
// composition). `null` because the default per-element `sanitize_text_field` |
| 196 |
// would flatten the nested structure and corrupt the base64 logo; validated |
| 197 |
// structurally in the handler instead. |
| 198 |
'customization_data' => [ |
| 199 |
'required' => true, |
| 200 |
'sanitize_callback' => null, |
| 201 |
], |
| 202 |
// Whether to also create/update the CLOUD conversation. False during the |
| 203 |
// import, true at the finalizer — see the handler. |
| 204 |
'sync_cloud' => [ |
| 205 |
'required' => false, |
| 206 |
'default' => false, |
| 207 |
'sanitize_callback' => null, |
| 208 |
], |
| 209 |
]); |
| 210 |
// /images (search_images) moved to modules/image-replace/REST/ImageSearch.php (spec 023). |
| 211 |
// die(rest_url( 'templately/v1/ai-content/ai-update' )); |
| 212 |
} |
| 213 |
|
| 214 |
/** |
| 215 |
* Extract + sanitize + validate the modify-content request params (033 US4 / T026). |
| 216 |
* |
| 217 |
* The DTO half of modify_content's former 181-line body, pulled out verbatim so the |
| 218 |
* endpoint reads as parse → call → build-row. Returns the request fields as an assoc |
| 219 |
* array, or a WP_Error (the validation/sanitize early-returns) the caller propagates. |
| 220 |
* Behaviour-identical: same params, same sanitize, same validation order. |
| 221 |
* |
| 222 |
* @return array|\WP_Error |
| 223 |
*/ |
| 224 |
private function parse_modify_content_request() { |
| 225 |
$session_id = $this->get_param('session_id'); |
| 226 |
// Security: Sanitize session_id if provided |
| 227 |
if (!empty($session_id)) { |
| 228 |
$session_id = AIUtils::sanitize_path_component($session_id, 'session_id'); |
| 229 |
if (is_wp_error($session_id)) { |
| 230 |
return $session_id; |
| 231 |
} |
| 232 |
} |
| 233 |
|
| 234 |
$req = [ |
| 235 |
'pack_id' => $this->get_param('pack_id'), |
| 236 |
'isBusinessNichesNew' => $this->get_param('isBusinessNichesNew', false), |
| 237 |
'ai_page_ids' => $this->get_param('ai_page_ids', [], null), |
| 238 |
'content_ids' => $this->get_param('content_ids', [], null), |
| 239 |
'session_id' => $session_id, |
| 240 |
'preview_pages' => $this->get_param('preview_pages', [], null), |
| 241 |
'image_replace' => $this->get_param('imageReplace', [], null), |
| 242 |
'platform' => $this->get_param('platform'), |
| 243 |
'language' => $this->get_param('language', null), |
| 244 |
// ai content fields |
| 245 |
'name' => $this->get_param('name'), |
| 246 |
'category' => $this->get_param('category'), |
| 247 |
'description' => $this->get_param('description'), |
| 248 |
'email' => $this->get_param('email'), |
| 249 |
'contactNumber' => $this->get_param('contactNumber'), |
| 250 |
'businessAddress' => $this->get_param('businessAddress'), |
| 251 |
'openingHour' => $this->get_param('openingHour'), |
| 252 |
'requested_platform' => Helper::get_requested_platform(), |
| 253 |
]; |
| 254 |
|
| 255 |
if (empty($req['pack_id'])) { |
| 256 |
return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'modify_content', 400); |
| 257 |
} |
| 258 |
if (empty($req['category'])) { |
| 259 |
return $this->error('invalid_prompt', __('Invalid prompt.', 'templately'), 'modify_content', 400); |
| 260 |
} |
| 261 |
if (empty($req['content_ids']) && empty($req['preview_pages'])) { |
| 262 |
return $this->error('invalid_content_ids', __('Invalid content ids.', 'templately'), 'modify_content', 400); |
| 263 |
} |
| 264 |
if (empty($req['platform'])) { |
| 265 |
return $this->error('invalid_platform', __('Invalid platform.', 'templately'), 'modify_content', 400); |
| 266 |
} |
| 267 |
|
| 268 |
return $req; |
| 269 |
} |
| 270 |
|
| 271 |
public function modify_content() { |
| 272 |
add_filter('wp_redirect', '__return_false', 999); |
| 273 |
set_time_limit(3 * MINUTE_IN_SECONDS); |
| 274 |
ini_set('max_execution_time', 3 * MINUTE_IN_SECONDS); |
| 275 |
|
| 276 |
$req = $this->parse_modify_content_request(); |
| 277 |
if (is_wp_error($req)) { |
| 278 |
return $req; |
| 279 |
} |
| 280 |
// Re-establish the locals the rest of this method uses (keyed list destructuring, |
| 281 |
// PHP 7.1+) so the body below stays byte-identical to the pre-T026 implementation. |
| 282 |
[ |
| 283 |
'pack_id' => $pack_id, |
| 284 |
'isBusinessNichesNew' => $isBusinessNichesNew, |
| 285 |
'ai_page_ids' => $ai_page_ids, |
| 286 |
'content_ids' => $content_ids, |
| 287 |
'session_id' => $session_id, |
| 288 |
'preview_pages' => $preview_pages, |
| 289 |
'image_replace' => $image_replace, |
| 290 |
'platform' => $platform, |
| 291 |
'language' => $language, |
| 292 |
'name' => $name, |
| 293 |
'category' => $category, |
| 294 |
'description' => $description, |
| 295 |
'email' => $email, |
| 296 |
'contactNumber' => $contactNumber, |
| 297 |
'businessAddress' => $businessAddress, |
| 298 |
'openingHour' => $openingHour, |
| 299 |
'requested_platform' => $requested_platform, |
| 300 |
] = $req; |
| 301 |
|
| 302 |
|
| 303 |
// $response = get_transient( '__templately_ai_process_id' ); |
| 304 |
|
| 305 |
// if(empty($response)) { |
| 306 |
$extra_headers = [ |
| 307 |
'Accept' => 'application/json', |
| 308 |
'x-templately-session-id' => $session_id, |
| 309 |
'x-templately-requested-platform' => $requested_platform, |
| 310 |
]; |
| 311 |
$body_data = [ |
| 312 |
'business_name' => $name, |
| 313 |
'business_niches' => $category, |
| 314 |
'prompt' => $description, |
| 315 |
'email' => $email, |
| 316 |
'phone' => $contactNumber, |
| 317 |
'address' => $businessAddress, |
| 318 |
'openingHour' => $openingHour, |
| 319 |
'pack_id' => $pack_id, |
| 320 |
'content_ids' => $content_ids, |
| 321 |
'platform' => $platform, |
| 322 |
'preview_pages' => $preview_pages, |
| 323 |
'language' => $language, |
| 324 |
'callback' => defined('TEMPLATELY_CALLBACK') ? TEMPLATELY_CALLBACK . '/wp-json/templately/v1/ai-content/ai-update' : rest_url('templately/v1/ai-content/ai-update'), |
| 325 |
]; |
| 326 |
// The `templately_ai_modify_content_body_data` filter was removed 2026-07-30 along |
| 327 |
// with the developer AI Settings tab that was its only consumer. It existed to stamp |
| 328 |
// an `ai_model` parameter onto this request; the cloud no longer supports that |
| 329 |
// parameter, so the seam had nothing legitimate left to add and keeping it invited |
| 330 |
// re-introducing a field the API rejects. |
| 331 |
|
| 332 |
// `unwrap => false`: the handler below reads `status`/`process_id` from the |
| 333 |
// TOP level, and treats a body-level `status:'error'` as a result to pass |
| 334 |
// through rather than a hard failure — so the envelope must stay intact. |
| 335 |
// The REST client logs nothing (unlike `Http`, which logs URL/QUERY/RESPONSE for |
| 336 |
// every GraphQL call), so an AI generation used to leave NO trace at all — a failed |
| 337 |
// run was indistinguishable from one that never started. Mark the attempt with the |
| 338 |
// identifiers needed to correlate it with the session log and the process row. The |
| 339 |
// business copy and the API key are deliberately NOT logged. |
| 340 |
Helper::log( |
| 341 |
sprintf( |
| 342 |
'modify_content → v2/ai/modify-content/pack (pack_id=%s, platform=%s, language=%s, session=%s, content_ids=%d, preview_pages=%d, local=%s)', |
| 343 |
$pack_id, |
| 344 |
$platform, |
| 345 |
'' === $language ? '(default)' : $language, |
| 346 |
$session_id, |
| 347 |
is_array($content_ids) ? count($content_ids) : 0, |
| 348 |
is_array($preview_pages) ? count($preview_pages) : 0, |
| 349 |
Helper::is_local_site() ? 'yes' : 'no' |
| 350 |
), |
| 351 |
'modify_content', |
| 352 |
'info' |
| 353 |
); |
| 354 |
|
| 355 |
$normalized = Helper::api_post('v2/ai/modify-content/pack', $body_data, $extra_headers, 15 * MINUTE_IN_SECONDS, ['unwrap' => false]); |
| 356 |
|
| 357 |
// set_transient( '__templately_ai_process_id', $response, 60 * 60 * 24 * 30 ); |
| 358 |
// } |
| 359 |
|
| 360 |
// The business-niche value is destructured as $category (sent as |
| 361 |
// body_data['business_niches'] above). The previous $business_niches local was |
| 362 |
// never defined — so this "remember my niche" persistence silently never ran and |
| 363 |
// emitted a PHP 8 undefined-variable warning on every AI generation. Use $category. |
| 364 |
$bk_ai_business_niches = get_option('templately_ai_business_niches', []); |
| 365 |
if (!empty($category) && $isBusinessNichesNew && ! in_array($category, $bk_ai_business_niches)) { |
| 366 |
$bk_ai_business_niches[] = $category; |
| 367 |
update_option('templately_ai_business_niches', $bk_ai_business_niches, false); |
| 368 |
} |
| 369 |
|
| 370 |
// A transport/protocol failure is terminal. A body-level `status:'error'` is |
| 371 |
// NOT — it is a legitimate outcome this endpoint forwards to the client, so it |
| 372 |
// is deliberately allowed through to the handling below. |
| 373 |
if ($normalized->is_error() && ErrorCode::INVALID_REQUEST !== $normalized->error()->code()) { |
| 374 |
$error = $normalized->error(); |
| 375 |
Helper::log("modify_content failed: {$error->code()} — {$error->message()}", 'modify_content_error', 'error'); |
| 376 |
|
| 377 |
return $this->error($error->code(), $error->message(), 'modify_content', $error->status()); |
| 378 |
} |
| 379 |
|
| 380 |
$data = $normalized->is_error() |
| 381 |
? ['status' => 'error', 'message' => $normalized->error()->message()] |
| 382 |
: $normalized->payload(); |
| 383 |
|
| 384 |
if (! is_array($data) || ! isset($data['status'])) { |
| 385 |
return $this->error(ErrorCode::SERVER_ERROR, __('Invalid response.', 'templately'), 'modify_content', 500); |
| 386 |
} |
| 387 |
|
| 388 |
// "{"status":"success","message":"The content is being generated in the queue","process_id":"01JRQQD39GNWTNF18EWF8YH0BG-271838-pack-408"}" |
| 389 |
if (isset($data['status']) && $data['status'] === 'success' && isset($data['process_id'])) { |
| 390 |
$process_id = $data['process_id']; |
| 391 |
|
| 392 |
// // Save templates to files if available using the common function |
| 393 |
// if (!empty($data['templates']) && is_array($data['templates'])) { |
| 394 |
// foreach ($data['templates'] as $content_id => $template_data) { |
| 395 |
// // Decode template if it's base64 encoded |
| 396 |
// if (! empty($template_data) && base64_decode($template_data, true) !== false) { |
| 397 |
// $data['templates'][$content_id] = base64_decode($template_data); |
| 398 |
// } |
| 399 |
|
| 400 |
// if (!empty($template_data)) { |
| 401 |
// AIUtils::save_template_to_file( |
| 402 |
// $process_id, |
| 403 |
// $content_id, |
| 404 |
// $template_data, |
| 405 |
// $ai_page_ids, |
| 406 |
// true, // Always use preview mode for AI content workflow |
| 407 |
// isset($template_data['isSkipped']) ? $template_data['isSkipped'] : false |
| 408 |
// ); |
| 409 |
// } |
| 410 |
// } |
| 411 |
// } |
| 412 |
|
| 413 |
$user = $this->utils('options')->get('user'); |
| 414 |
|
| 415 |
// FR-004: is_local_site is decided by the backend, not supplied by the |
| 416 |
// client or trusted from the remote pass-through. |
| 417 |
$is_local_site = Helper::is_local_site(); |
| 418 |
|
| 419 |
$ai_process_data[$process_id] = [ |
| 420 |
'name' => $name, |
| 421 |
'category' => $category, |
| 422 |
'description' => $description, |
| 423 |
'email' => $email, |
| 424 |
'contactNumber' => $contactNumber, |
| 425 |
'businessAddress' => $businessAddress, |
| 426 |
'openingHour' => $openingHour, |
| 427 |
'process_id' => $process_id, |
| 428 |
'pack_id' => $pack_id, |
| 429 |
'ai_page_ids' => $ai_page_ids, |
| 430 |
'ai_preview_ids' => $preview_pages, |
| 431 |
'content_ids' => $content_ids, |
| 432 |
'platform' => $platform, |
| 433 |
'api_key' => $this->api_key, |
| 434 |
'user_id' => isset($user['id']) ? $user['id'] : null, |
| 435 |
'session_id' => $session_id, |
| 436 |
'is_local_site' => $is_local_site, |
| 437 |
'imageReplace' => $image_replace, |
| 438 |
'language' => $language, |
| 439 |
'requested_platform' => $requested_platform, |
| 440 |
]; |
| 441 |
|
| 442 |
// Persist the process record (per-process non-autoloaded row, 026 seam). |
| 443 |
AIUtils::update_ai_process_data($ai_process_data); |
| 444 |
|
| 445 |
// FR-005: link the generation process to its import session server-side |
| 446 |
// so the client never re-submits these identifiers. Mirror is_local_site |
| 447 |
// onto the session for the import pipeline. |
| 448 |
if (!empty($session_id)) { |
| 449 |
AIUtils::link_session($process_id, $session_id); |
| 450 |
SessionData::set($session_id, 'isLocalSite', $is_local_site); |
| 451 |
} |
| 452 |
|
| 453 |
// FR-003: additive {success,terminal,code,message} envelope; the |
| 454 |
// generation request itself succeeded (the client now polls for content). |
| 455 |
return self::ai_envelope('ok', __('The content is being generated in the queue', 'templately'), [ |
| 456 |
'status' => 'success', |
| 457 |
'process_id' => $process_id, |
| 458 |
'templates' => !empty($data['templates']) ? $data['templates'] : null, |
| 459 |
// FR-004: server-decided, authoritative. |
| 460 |
'is_local_site' => $is_local_site, |
| 461 |
]); |
| 462 |
} |
| 463 |
|
| 464 |
// Everything above returns early on success. Reaching here means the cloud |
| 465 |
// answered with a non-success status, or with success but no `process_id` — and |
| 466 |
// this pass-through used to return it to the client WITHOUT a word in the log, |
| 467 |
// which is why a "failed to generate content" in the UI had nothing behind it to |
| 468 |
// read. It is not an error from this endpoint's point of view (the call itself |
| 469 |
// worked), so it is still passed through; it is now merely audible. |
| 470 |
Helper::log( |
| 471 |
sprintf( |
| 472 |
'modify_content: cloud declined to start generation (pack_id=%s, platform=%s, status=%s, process_id=%s) — %s', |
| 473 |
$pack_id, |
| 474 |
$platform, |
| 475 |
isset($data['status']) ? (string) $data['status'] : '(none)', |
| 476 |
isset($data['process_id']) ? (string) $data['process_id'] : '(none)', |
| 477 |
isset($data['message']) ? (string) $data['message'] : '(no message)' |
| 478 |
), |
| 479 |
'modify_content', |
| 480 |
'error' |
| 481 |
); |
| 482 |
|
| 483 |
return $data; |
| 484 |
} |
| 485 |
|
| 486 |
public function ai_update() { |
| 487 |
add_filter('wp_redirect', '__return_false', 999); |
| 488 |
|
| 489 |
$template = $this->get_param('template'); |
| 490 |
$process_id = $this->get_param('process_id'); |
| 491 |
$template_id = $this->get_param('template_id'); |
| 492 |
$content_id = $this->get_param('content_id'); |
| 493 |
$type = $this->get_param('type'); |
| 494 |
$isSkipped = $this->get_param('isSkipped', false); |
| 495 |
$credit_cost = $this->request->get_param('credit_cost'); |
| 496 |
|
| 497 |
Helper::log('process_id: ' . $process_id, 'ai_update', 'debug'); // gated by WP_DEBUG_LOG |
| 498 |
|
| 499 |
// Handle credit cost updates separately |
| 500 |
if ($this->request->has_param('credit_cost')) { |
| 501 |
AIUtils::merge_process_row($process_id, ['credit_cost' => $credit_cost]); |
| 502 |
|
| 503 |
return self::ai_envelope('ok', '', [ |
| 504 |
'status' => 'success', |
| 505 |
'data' => [ |
| 506 |
'process_id' => $process_id, |
| 507 |
'credit_cost' => $credit_cost, |
| 508 |
], |
| 509 |
]); |
| 510 |
} |
| 511 |
|
| 512 |
// Always use preview mode for AI content workflow |
| 513 |
// Validate and get process data using centralized method |
| 514 |
$process_data = AIUtils::validate_and_get_process_data($process_id); |
| 515 |
if (is_wp_error($process_data)) { |
| 516 |
return $process_data; |
| 517 |
} |
| 518 |
|
| 519 |
$session_id = $process_data['session_id']; |
| 520 |
$ai_page_ids = $process_data['ai_page_ids']; |
| 521 |
|
| 522 |
// Use the common helper function to save the template |
| 523 |
$result = AIUtils::save_template_to_file( |
| 524 |
$process_id, |
| 525 |
$session_id, |
| 526 |
$content_id, |
| 527 |
$template, |
| 528 |
$ai_page_ids, |
| 529 |
$isSkipped |
| 530 |
); |
| 531 |
|
| 532 |
if(is_wp_error($result)){ |
| 533 |
return $result; |
| 534 |
} |
| 535 |
|
| 536 |
// Return the result from the helper function (FR-003 additive envelope). |
| 537 |
if (isset($result['status']) && $result['status'] === 'success') { |
| 538 |
return self::ai_envelope('ok', '', $result); |
| 539 |
} |
| 540 |
|
| 541 |
// Return error if the helper function failed |
| 542 |
return $result; |
| 543 |
} |
| 544 |
|
| 545 |
public function ai_update_preview() { |
| 546 |
add_filter('wp_redirect', '__return_false', 999); |
| 547 |
|
| 548 |
$template = $this->get_param('templates'); // Now expects an array with content_id as keys |
| 549 |
$process_id = $this->get_param('process_id'); |
| 550 |
$isSkipped = $this->get_param('isSkipped', false); |
| 551 |
$error = $this->get_param('error', null); |
| 552 |
|
| 553 |
Helper::log('process_id: ' . $process_id, 'ai_update', 'debug'); // gated by WP_DEBUG_LOG |
| 554 |
|
| 555 |
if (!empty($isSkipped) || !empty($error)) { |
| 556 |
// The cloud reporting a generation failure is THE answer to "why did this |
| 557 |
// fail", and it was recorded only on the process row — a serialized option |
| 558 |
// nobody reads while debugging. Put it in the log too. |
| 559 |
Helper::log( |
| 560 |
sprintf( |
| 561 |
'ai_update_preview: remote reported failure for %s (isSkipped=%s) — %s', |
| 562 |
$process_id, |
| 563 |
!empty($isSkipped) ? 'yes' : 'no', |
| 564 |
!empty($error) ? (string) $error : '(no message)' |
| 565 |
), |
| 566 |
'ai_update_preview', |
| 567 |
'error' |
| 568 |
); |
| 569 |
|
| 570 |
// Persist the preview error onto the process row (026 seam). |
| 571 |
if (AIUtils::read_process($process_id) !== null) { |
| 572 |
AIUtils::merge_process_row($process_id, ['preview_error' => $error]); |
| 573 |
} |
| 574 |
// This is a REST route (registered via $this->post()), so it must RETURN. |
| 575 |
// `wp_send_json_error()` die()s mid-request and emits an AJAX-shaped body, |
| 576 |
// bypassing the REST envelope, the HTTP status mapping and every filter |
| 577 |
// after dispatch — the one place in the codebase where a REST handler |
| 578 |
// answered in AJAX. Every other exit in this method already returns. |
| 579 |
return $this->error( |
| 580 |
ErrorCode::AI_REMOTE_FAILED, |
| 581 |
! empty( $error ) ? $error : __( 'AI preview generation did not complete.', 'templately' ), |
| 582 |
'ai-content/ai-update-preview', |
| 583 |
502 |
| 584 |
); |
| 585 |
} |
| 586 |
|
| 587 |
// Validate template parameter is an array |
| 588 |
if (!is_array($template) || empty($template)) { |
| 589 |
return $this->error('invalid_template', __('Template must be a non-empty array with content_id as keys.', 'templately'), 'ai-content/ai-update-preview', 400); |
| 590 |
} |
| 591 |
|
| 592 |
// Always use preview mode for AI content workflow |
| 593 |
// Validate and get process data using centralized method |
| 594 |
$process_data = AIUtils::validate_and_get_process_data($process_id); |
| 595 |
if (is_wp_error($process_data)) { |
| 596 |
return $process_data; |
| 597 |
} |
| 598 |
|
| 599 |
$session_id = $process_data['session_id']; |
| 600 |
$ai_page_ids = $process_data['ai_page_ids']; |
| 601 |
$results = []; |
| 602 |
$success_count = 0; |
| 603 |
$error_count = 0; |
| 604 |
|
| 605 |
// Process each content_id/template pair |
| 606 |
foreach ($template as $content_id => $template_data) { |
| 607 |
// Use the common helper function to save the template (always preview mode) |
| 608 |
$result = AIUtils::save_template_to_file( |
| 609 |
$process_id, |
| 610 |
$session_id, |
| 611 |
$content_id, |
| 612 |
$template_data, |
| 613 |
$ai_page_ids, |
| 614 |
$isSkipped |
| 615 |
); |
| 616 |
|
| 617 |
$results[$content_id] = $result; |
| 618 |
|
| 619 |
// Track success/error counts |
| 620 |
if (isset($result['status']) && $result['status'] === 'success') { |
| 621 |
$success_count++; |
| 622 |
} else { |
| 623 |
$error_count++; |
| 624 |
} |
| 625 |
} |
| 626 |
|
| 627 |
// Return consolidated response |
| 628 |
$overall_status = $error_count === 0 ? 'success' : ($success_count === 0 ? 'error' : 'partial_success'); |
| 629 |
|
| 630 |
// Note: No cleanup needed with API key-based storage and count-based management |
| 631 |
|
| 632 |
// FR-003 additive envelope: a total failure is terminal (remote_failed); |
| 633 |
// full or partial success is 'ok' (the per-page results carry the detail). |
| 634 |
$code = $overall_status === 'error' ? 'remote_failed' : 'ok'; |
| 635 |
|
| 636 |
return self::ai_envelope($code, sprintf( |
| 637 |
__('Processed %d templates: %d successful, %d failed.', 'templately'), |
| 638 |
count($template), |
| 639 |
$success_count, |
| 640 |
$error_count |
| 641 |
), [ |
| 642 |
'status' => $overall_status, |
| 643 |
]); |
| 644 |
} |
| 645 |
|
| 646 |
|
| 647 |
|
| 648 |
/** |
| 649 |
* Get attachments from API endpoint |
| 650 |
* |
| 651 |
* @return array|\WP_Error |
| 652 |
*/ |
| 653 |
public function get_attachments() { |
| 654 |
// Get parameters from request. The route declares BOTH `id` and `pack_id` and the |
| 655 |
// error copy says "Pack ID or ID" — but only `pack_id` was read, so a caller |
| 656 |
// passing `?id=` (the documented alternative, matching the get-xml-attachment/{type}/{id} |
| 657 |
// URL shape) failed with missing_id. Fall back to `id` when `pack_id` is absent. |
| 658 |
$type = $this->get_param('type', 'pack'); |
| 659 |
$id = $this->get_param('pack_id'); |
| 660 |
if (empty($id)) { |
| 661 |
$id = $this->get_param('id'); |
| 662 |
} |
| 663 |
$requested_platform = Helper::get_requested_platform(); |
| 664 |
|
| 665 |
// Require ID parameter - return error if not provided |
| 666 |
if (empty($id)) { |
| 667 |
return $this->error('missing_id', __('Pack ID or ID parameter is required.', 'templately'), 'get_attachments', 400); |
| 668 |
} |
| 669 |
|
| 670 |
try { |
| 671 |
// Construct API endpoint URL |
| 672 |
$api_endpoint = "get-xml-attachment/{$type}/{$id}"; |
| 673 |
|
| 674 |
// Make API call |
| 675 |
$extra_headers = [ |
| 676 |
'Accept' => 'application/xml, text/xml', |
| 677 |
'x-templately-requested-platform' => $requested_platform, |
| 678 |
]; |
| 679 |
// XML, not JSON — FR-012: the body passes through the normalizer untouched |
| 680 |
// and is only CLASSIFIED. An error body on this endpoint is JSON even |
| 681 |
// though success is XML, so it is re-checked below. |
| 682 |
$normalized = Helper::api_get("v2/$api_endpoint", [], $extra_headers, 30, ['raw' => true]); |
| 683 |
|
| 684 |
if ($normalized->is_error()) { |
| 685 |
$error = $normalized->error(); |
| 686 |
|
| 687 |
return $this->error($error->code(), $error->message(), 'get_attachments', $error->status()); |
| 688 |
} |
| 689 |
|
| 690 |
$xml_content = $normalized->payload(); |
| 691 |
|
| 692 |
// Validate we have XML content |
| 693 |
if (empty($xml_content)) { |
| 694 |
return $this->error('no_xml_content', __('No XML content found in API response.', 'templately'), 'get_attachments', 404); |
| 695 |
} |
| 696 |
|
| 697 |
// Parse the XML content from API response |
| 698 |
$parsed_data = $this->parse_xml_content($xml_content); |
| 699 |
|
| 700 |
if (is_wp_error($parsed_data)) { |
| 701 |
return $this->error('xml_parse_error', __('Failed to parse XML content.', 'templately'), 'get_attachments', 500, ['error_detail' => $parsed_data->get_error_message()]); |
| 702 |
} |
| 703 |
|
| 704 |
// Extract attachments from parsed data |
| 705 |
$attachments = $this->extract_attachments_from_parsed_data($parsed_data); |
| 706 |
|
| 707 |
return [ |
| 708 |
'status' => 'success', |
| 709 |
'data' => $attachments, |
| 710 |
'message' => sprintf(__('Found %d attachments.', 'templately'), count($attachments)), |
| 711 |
]; |
| 712 |
|
| 713 |
} catch (Exception $e) { |
| 714 |
return $this->error('exception', __('An unexpected error occurred while fetching attachments.', 'templately'), 'get_attachments', 500, ['error_detail' => $e->getMessage()]); |
| 715 |
} |
| 716 |
} |
| 717 |
|
| 718 |
|
| 719 |
|
| 720 |
/** |
| 721 |
* Parse XML content string using WXR Parser |
| 722 |
* |
| 723 |
* @param string $xml_content XML content string |
| 724 |
* @return array|\WP_Error Parsed data or error |
| 725 |
*/ |
| 726 |
private function parse_xml_content($xml_content) { |
| 727 |
// Ensure WordPress filesystem functions are available |
| 728 |
if (!function_exists('wp_tempnam')) { |
| 729 |
require_once(ABSPATH . 'wp-admin/includes/file.php'); |
| 730 |
} |
| 731 |
|
| 732 |
// Create a temporary file to store XML content |
| 733 |
$temp_file = wp_tempnam('templately_attachments'); |
| 734 |
if (!$temp_file) { |
| 735 |
return new WP_Error('temp_file_failed', __('Failed to create temporary file.', 'templately')); |
| 736 |
} |
| 737 |
|
| 738 |
// Write XML content to temporary file |
| 739 |
$bytes_written = file_put_contents($temp_file, $xml_content); |
| 740 |
if ($bytes_written === false) { |
| 741 |
unlink($temp_file); |
| 742 |
return new WP_Error('write_failed', __('Failed to write XML content to temporary file.', 'templately')); |
| 743 |
} |
| 744 |
|
| 745 |
try { |
| 746 |
// Initialize WXR Parser |
| 747 |
$parser = new WXR_Parser(); |
| 748 |
|
| 749 |
// Parse the temporary XML file |
| 750 |
$parsed_data = $parser->parse($temp_file); |
| 751 |
|
| 752 |
// Clean up temporary file |
| 753 |
unlink($temp_file); |
| 754 |
|
| 755 |
return $parsed_data; |
| 756 |
|
| 757 |
} catch (Exception $e) { |
| 758 |
// Clean up temporary file on exception |
| 759 |
if (file_exists($temp_file)) { |
| 760 |
unlink($temp_file); |
| 761 |
} |
| 762 |
return new WP_Error('parse_exception', $e->getMessage()); |
| 763 |
} |
| 764 |
} |
| 765 |
|
| 766 |
/** |
| 767 |
* Extract attachments from parsed WXR data |
| 768 |
* |
| 769 |
* @param array $parsed_data Parsed WXR data |
| 770 |
* @return array Array of attachment data |
| 771 |
*/ |
| 772 |
private function extract_attachments_from_parsed_data($parsed_data) { |
| 773 |
$attachments = []; |
| 774 |
|
| 775 |
if (isset($parsed_data['posts']) && is_array($parsed_data['posts'])) { |
| 776 |
foreach ($parsed_data['posts'] as $post) { |
| 777 |
// Check if this is an attachment |
| 778 |
if (isset($post['post_type']) && $post['post_type'] === 'attachment') { |
| 779 |
$attachment = [ |
| 780 |
'id' => isset($post['post_id']) ? (int) $post['post_id'] : 0, |
| 781 |
'url' => isset($post['attachment_url']) ? (string) $post['attachment_url'] : '', |
| 782 |
'title' => isset($post['post_title']) ? (string) $post['post_title'] : '', |
| 783 |
'type' => isset($post['attachment_type']) ? (string) $post['attachment_type'] : '', |
| 784 |
]; |
| 785 |
|
| 786 |
// Extract metadata including dimensions and medium URL |
| 787 |
$metadata = $this->extract_medium_size_url($post, $attachment['url']); |
| 788 |
|
| 789 |
// Filter out small images (width or height <= 150px) to ignore small icons |
| 790 |
if ($metadata && isset($metadata['width']) && isset($metadata['height'])) { |
| 791 |
if ($metadata['width'] < 150 || $metadata['height'] < 150) { |
| 792 |
continue; // Skip small images/icons |
| 793 |
} |
| 794 |
|
| 795 |
// Add dimensions to attachment data |
| 796 |
$attachment['width'] = $metadata['width']; |
| 797 |
$attachment['height'] = $metadata['height']; |
| 798 |
|
| 799 |
// Add medium URL if available |
| 800 |
if (isset($metadata['medium_url'])) { |
| 801 |
$attachment['medium_url'] = $metadata['medium_url']; |
| 802 |
} |
| 803 |
} else { |
| 804 |
// Skip attachments without metadata or dimensions |
| 805 |
continue; |
| 806 |
} |
| 807 |
|
| 808 |
// Only add if we have the required data |
| 809 |
if ($attachment['id'] && $attachment['url'] && $attachment['title']) { |
| 810 |
$attachments[] = $attachment; |
| 811 |
} |
| 812 |
} |
| 813 |
} |
| 814 |
} |
| 815 |
|
| 816 |
return $attachments; |
| 817 |
} |
| 818 |
|
| 819 |
/** |
| 820 |
* Extract medium size URL from attachment metadata and get image dimensions |
| 821 |
* |
| 822 |
* @param array $post Post data from WXR parser |
| 823 |
* @param string $original_url Original attachment URL |
| 824 |
* @return array|null Array with medium_url and dimensions if found, null otherwise |
| 825 |
*/ |
| 826 |
private function extract_medium_size_url($post, $original_url) { |
| 827 |
if (!isset($post['postmeta']) || !is_array($post['postmeta'])) { |
| 828 |
return null; |
| 829 |
} |
| 830 |
|
| 831 |
foreach ($post['postmeta'] as $meta) { |
| 832 |
if (!isset($meta['key']) || !isset($meta['value'])) { |
| 833 |
continue; |
| 834 |
} |
| 835 |
|
| 836 |
// Only check _wp_attachment_metadata |
| 837 |
if ($meta['key'] === '_wp_attachment_metadata') { |
| 838 |
// Pack-supplied meta: parse without object hydration, and without @ |
| 839 |
// (a malformed value just fails the is_array check below). |
| 840 |
$attachment_metadata = is_serialized($meta['value']) |
| 841 |
? unserialize($meta['value'], ['allowed_classes' => false]) |
| 842 |
: null; |
| 843 |
if (is_array($attachment_metadata)) { |
| 844 |
$result = []; |
| 845 |
|
| 846 |
// Get original image dimensions |
| 847 |
$width = isset($attachment_metadata['width']) ? (int) $attachment_metadata['width'] : 0; |
| 848 |
$height = isset($attachment_metadata['height']) ? (int) $attachment_metadata['height'] : 0; |
| 849 |
|
| 850 |
$result['width'] = $width; |
| 851 |
$result['height'] = $height; |
| 852 |
|
| 853 |
// Check if medium size exists |
| 854 |
if (isset($attachment_metadata['sizes']['medium']['file'])) { |
| 855 |
// Construct medium URL from original URL and medium filename |
| 856 |
$medium_filename = $attachment_metadata['sizes']['medium']['file']; |
| 857 |
$original_path = dirname(parse_url($original_url, PHP_URL_PATH)); |
| 858 |
$base_url = str_replace(parse_url($original_url, PHP_URL_PATH), '', $original_url); |
| 859 |
$result['medium_url'] = $base_url . $original_path . '/' . $medium_filename; |
| 860 |
} |
| 861 |
|
| 862 |
return $result; |
| 863 |
} |
| 864 |
} |
| 865 |
} |
| 866 |
|
| 867 |
return null; |
| 868 |
} |
| 869 |
|
| 870 |
|
| 871 |
|
| 872 |
// search_images() moved to modules/image-replace/REST/ImageSearch.php (spec 023). |
| 873 |
|
| 874 |
|
| 875 |
|
| 876 |
/** |
| 877 |
* Generate tagline using AI |
| 878 |
* |
| 879 |
* @return array|WP_Error |
| 880 |
*/ |
| 881 |
public function generate_tagline() { |
| 882 |
// Get parameters |
| 883 |
$prompt = $this->get_param('prompt'); |
| 884 |
$requested_platform = Helper::get_requested_platform(); |
| 885 |
|
| 886 |
// Validate required parameters |
| 887 |
if (empty($prompt)) { |
| 888 |
return $this->error( |
| 889 |
'missing_prompt', |
| 890 |
__('Prompt is required for tagline generation.', 'templately'), |
| 891 |
'generate_tagline', |
| 892 |
400 |
| 893 |
); |
| 894 |
} |
| 895 |
|
| 896 |
// Prepare request body |
| 897 |
$body_data = [ |
| 898 |
'prompt' => $prompt, |
| 899 |
]; |
| 900 |
|
| 901 |
// Make API request |
| 902 |
$extra_headers = [ |
| 903 |
'Content-Type' => 'application/json', |
| 904 |
'x-templately-requested-platform' => $requested_platform, |
| 905 |
]; |
| 906 |
|
| 907 |
$response = Helper::make_api_post_request('v2/generate-tagline', $body_data, $extra_headers, 30); |
| 908 |
|
| 909 |
// Handle API response errors |
| 910 |
if (is_wp_error($response)) { |
| 911 |
return $this->error( |
| 912 |
'api_request_failed', |
| 913 |
__('Failed to generate tagline.', 'templately'), |
| 914 |
'generate_tagline', |
| 915 |
500, |
| 916 |
['error_detail' => $response->get_error_message()] |
| 917 |
); |
| 918 |
} |
| 919 |
|
| 920 |
$response_code = wp_remote_retrieve_response_code($response); |
| 921 |
$response_body = wp_remote_retrieve_body($response); |
| 922 |
|
| 923 |
if ($response_code !== 200) { |
| 924 |
// Try to parse the response body as JSON to get specific error details |
| 925 |
$data = json_decode($response_body, true); |
| 926 |
|
| 927 |
// If valid JSON, extract error message and return with proper status code |
| 928 |
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) { |
| 929 |
$error_message = isset($data['message']) ? $data['message'] : __('Something went wrong. Please try again or contact support.', 'templately'); |
| 930 |
return $this->error( |
| 931 |
'api_response_error', |
| 932 |
$error_message, |
| 933 |
'generate_tagline', |
| 934 |
$response_code |
| 935 |
); |
| 936 |
} |
| 937 |
|
| 938 |
// Otherwise, return generic error |
| 939 |
return $this->error( |
| 940 |
'api_response_error', |
| 941 |
__('Something went wrong. Please try again or contact support.', 'templately'), |
| 942 |
'generate_tagline', |
| 943 |
$response_code |
| 944 |
); |
| 945 |
} |
| 946 |
|
| 947 |
// Parse and validate response |
| 948 |
$data = json_decode($response_body, true); |
| 949 |
if (json_last_error() !== JSON_ERROR_NONE) { |
| 950 |
return $this->error( |
| 951 |
'invalid_response', |
| 952 |
__('Invalid response from API.', 'templately'), |
| 953 |
'generate_tagline', |
| 954 |
500 |
| 955 |
); |
| 956 |
} |
| 957 |
|
| 958 |
// Check if the response has the expected structure |
| 959 |
if (!isset($data['status'])) { |
| 960 |
return $this->error( |
| 961 |
'api_response_error', |
| 962 |
__('API returned an unexpected response.', 'templately'), |
| 963 |
'generate_tagline', |
| 964 |
500 |
| 965 |
); |
| 966 |
} |
| 967 |
|
| 968 |
// Same rule as {@see chatbot_request()}: a body-level `status:'error'` on an |
| 969 |
// HTTP 200 is a failure, not a payload. Inline rather than routed through |
| 970 |
// that helper because this is `v2/generate-tagline`, not a chatbot route — |
| 971 |
// folding it in would widen a chatbot-specific seam to mean "any upstream |
| 972 |
// call", which is how such helpers stop being safe to reason about. |
| 973 |
// |
| 974 |
// Defence in depth here specifically: `useTaglineGeneration` already checks |
| 975 |
// `response.status === 'error'` itself. This is the one of the four sites |
| 976 |
// where the frontend had compensated — but leaving the backend inconsistent |
| 977 |
// is how the next caller inherits the bug. |
| 978 |
if ('error' === $data['status']) { |
| 979 |
$message = !empty($data['message']) |
| 980 |
? $data['message'] |
| 981 |
: __('Failed to generate tagline.', 'templately'); |
| 982 |
|
| 983 |
return $this->error('tagline_generation_failed', $message, 'generate_tagline', 400); |
| 984 |
} |
| 985 |
|
| 986 |
// Return the response as-is |
| 987 |
return $data; |
| 988 |
} |
| 989 |
|
| 990 |
/** |
| 991 |
* Ask the cloud for a tagline AND typography-logo parameters in one call. |
| 992 |
* |
| 993 |
* A thin pass-through to `v2/generate-brand-kit`, same shape as {@see generate_tagline()}. |
| 994 |
* The font and icon vocabularies travel in the request because only the CLIENT knows what |
| 995 |
* its logo editor can render; sending them is what stops the model naming a font we cannot |
| 996 |
* load or an icon that exists nowhere. |
| 997 |
* |
| 998 |
* Nothing is validated against those vocabularies here. That check belongs where the |
| 999 |
* renderer is — the client re-checks every returned value and falls back to its own |
| 1000 |
* deterministic composition — and duplicating it in PHP would mean this endpoint had to |
| 1001 |
* track the icon registry's contents. |
| 1002 |
* |
| 1003 |
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error. |
| 1004 |
*/ |
| 1005 |
/** |
| 1006 |
* Persist the customizer draft for a generation — locally always, in the cloud on request. |
| 1007 |
* |
| 1008 |
* **Three cadences, and this is the slow two.** The moment-to-moment draft is a keystroke |
| 1009 |
* and lives in the browser's localStorage; a database write per keystroke is the wrong |
| 1010 |
* shape at any scale. This runs at two MILESTONES instead: when the import starts, and |
| 1011 |
* when it finishes. |
| 1012 |
* |
| 1013 |
* `sync_cloud` is what separates them. The import-start write is local only — fast, and |
| 1014 |
* an import that never completes should not leave a cloud record claiming a site exists. |
| 1015 |
* The finalizer's write carries `sync_cloud`, and THAT is where a site generated in this |
| 1016 |
* plugin becomes a conversation in "My AI Sites", re-importable anywhere. |
| 1017 |
* |
| 1018 |
* **Create-if-absent lives here, not in the client.** The conversation uuid is stored on |
| 1019 |
* the process row, so this endpoint knows whether one already exists: a web-generated site |
| 1020 |
* has one and gets an update, a plugin-generated site has none and gets a create whose |
| 1021 |
* minted uuid is written back. A client deciding that would have to be trusted with the |
| 1022 |
* uuid, and a second import would mint a duplicate. |
| 1023 |
* |
| 1024 |
* A cloud failure is reported but does NOT fail the call: the local write already |
| 1025 |
* succeeded, and the import it belongs to has finished. Losing the listing entry is worth |
| 1026 |
* far less than surfacing an error on a site that imported correctly. |
| 1027 |
* |
| 1028 |
* @return array|\WP_Error { status, data: { saved, uuid, cloud } } or WP_Error. |
| 1029 |
*/ |
| 1030 |
public function save_customization() { |
| 1031 |
$process_id = $this->get_param('process_id'); |
| 1032 |
$customization = $this->get_param('customization_data', [], null); |
| 1033 |
|
| 1034 |
if (empty($process_id)) { |
| 1035 |
return $this->error( |
| 1036 |
'missing_process_id', |
| 1037 |
__('A generation id is required to save customization.', 'templately'), |
| 1038 |
'ai-content/customization', |
| 1039 |
400 |
| 1040 |
); |
| 1041 |
} |
| 1042 |
|
| 1043 |
if (!is_array($customization)) { |
| 1044 |
return $this->error( |
| 1045 |
'invalid_customization', |
| 1046 |
__('Customization data must be an object.', 'templately'), |
| 1047 |
'ai-content/customization', |
| 1048 |
400 |
| 1049 |
); |
| 1050 |
} |
| 1051 |
|
| 1052 |
$row = AIUtils::read_process($process_id); |
| 1053 |
|
| 1054 |
if (!is_array($row)) { |
| 1055 |
// Nothing to attach it to. Not an error the user can act on — the draft still |
| 1056 |
// lives in their browser — but the caller should know the durable write did not |
| 1057 |
// happen rather than believing it did. |
| 1058 |
return $this->error( |
| 1059 |
'unknown_process', |
| 1060 |
__('That generation is no longer available on this site.', 'templately'), |
| 1061 |
'ai-content/customization', |
| 1062 |
404 |
| 1063 |
); |
| 1064 |
} |
| 1065 |
|
| 1066 |
// `processed_pages` is a MERGE-ON-READ view assembled from the per-page rows, not a |
| 1067 |
// stored field. Writing it back would bake a snapshot into the record and make the |
| 1068 |
// live per-page rows unreachable. |
| 1069 |
unset($row['processed_pages']); |
| 1070 |
|
| 1071 |
$row['customization_data'] = $customization; |
| 1072 |
AIUtils::write_process_row($process_id, $row); |
| 1073 |
|
| 1074 |
$result = [ |
| 1075 |
'saved' => true, |
| 1076 |
'uuid' => isset($row['conversation_uuid']) ? $row['conversation_uuid'] : null, |
| 1077 |
'cloud' => 'skipped', |
| 1078 |
]; |
| 1079 |
|
| 1080 |
if (!$this->get_param('sync_cloud', false, null)) { |
| 1081 |
return ['status' => 'success', 'data' => $result]; |
| 1082 |
} |
| 1083 |
|
| 1084 |
$body = ['customization_data' => $customization]; |
| 1085 |
|
| 1086 |
if (!empty($row['conversation_uuid'])) { |
| 1087 |
$body['uuid'] = $row['conversation_uuid']; |
| 1088 |
} else { |
| 1089 |
// Only on the CREATE. Without these the row appears in "My AI Sites" with no pack |
| 1090 |
// name, no thumbnail and — the one that does damage — a null platform, which is |
| 1091 |
// what a later re-import hands back to pick a builder with. |
| 1092 |
// |
| 1093 |
// The cloud fills name/slug/thumbnail from the live pack; it cannot know the |
| 1094 |
// PLATFORM this generation actually ran for, so that travels from here. |
| 1095 |
$body['selected_pack_id'] = isset($row['pack_id']) ? (int) $row['pack_id'] : null; |
| 1096 |
$body['platform'] = isset($row['platform']) ? $row['platform'] : null; |
| 1097 |
$body['detected_info'] = array_filter([ |
| 1098 |
'business_name' => isset($row['name']) ? $row['name'] : null, |
| 1099 |
'business_type' => isset($row['category']) ? $row['category'] : null, |
| 1100 |
'business_description' => isset($row['description']) ? $row['description'] : null, |
| 1101 |
]); |
| 1102 |
} |
| 1103 |
|
| 1104 |
$response = Helper::make_api_post_request( |
| 1105 |
'v2/chatbot/conversation/customization', |
| 1106 |
$body, |
| 1107 |
['Content-Type' => 'application/json'], |
| 1108 |
20 |
| 1109 |
); |
| 1110 |
|
| 1111 |
if (is_wp_error($response)) { |
| 1112 |
$result['cloud'] = 'failed'; |
| 1113 |
return ['status' => 'success', 'data' => $result]; |
| 1114 |
} |
| 1115 |
|
| 1116 |
$data = json_decode(wp_remote_retrieve_body($response), true); |
| 1117 |
$uuid = isset($data['data']['uuid']) ? $data['data']['uuid'] : null; |
| 1118 |
|
| 1119 |
if ($uuid) { |
| 1120 |
$result['uuid'] = $uuid; |
| 1121 |
$result['cloud'] = 'synced'; |
| 1122 |
|
| 1123 |
// Written back so the NEXT save updates that conversation instead of minting a |
| 1124 |
// second one. This is what makes the whole thing create-once. |
| 1125 |
if (empty($row['conversation_uuid'])) { |
| 1126 |
$row['conversation_uuid'] = $uuid; |
| 1127 |
AIUtils::write_process_row($process_id, $row); |
| 1128 |
} |
| 1129 |
} else { |
| 1130 |
$result['cloud'] = 'failed'; |
| 1131 |
} |
| 1132 |
|
| 1133 |
return ['status' => 'success', 'data' => $result]; |
| 1134 |
} |
| 1135 |
|
| 1136 |
public function generate_brand_kit() { |
| 1137 |
$business_name = $this->get_param('business_name'); |
| 1138 |
|
| 1139 |
if (empty($business_name)) { |
| 1140 |
return $this->error( |
| 1141 |
'missing_business_name', |
| 1142 |
__('Business name is required for brand kit generation.', 'templately'), |
| 1143 |
'generate_brand_kit', |
| 1144 |
400 |
| 1145 |
); |
| 1146 |
} |
| 1147 |
|
| 1148 |
// Arrays of plain identifiers (font families, icon LIBRARY names, hex colours). |
| 1149 |
// `sanitize_text_field` is applied per element by `get_param`, which is right for all |
| 1150 |
// three; the cloud caps their length and the client validates their contents. |
| 1151 |
$body_data = [ |
| 1152 |
'business_name' => $business_name, |
| 1153 |
'business_type' => $this->get_param('business_type', ''), |
| 1154 |
'business_description' => $this->get_param('business_description', ''), |
| 1155 |
// The rest of what the conversation collected. `language` is the one that was |
| 1156 |
// missing and was a live defect: without it the cloud writes the tagline in the |
| 1157 |
// language of its own instruction, so a French or Bengali site opened with an |
| 1158 |
// English tagline. `sanitize_email` for the address — `sanitize_text_field` |
| 1159 |
// would pass a malformed one straight through. |
| 1160 |
'email' => sanitize_email((string) $this->get_param('email', '', null)), |
| 1161 |
'contact_number' => $this->get_param('contact_number', ''), |
| 1162 |
'business_address' => $this->get_param('business_address', ''), |
| 1163 |
'opening_hour' => $this->get_param('opening_hour', ''), |
| 1164 |
'language' => $this->get_param('language', ''), |
| 1165 |
'fonts' => (array) $this->get_param('fonts', []), |
| 1166 |
'icon_libraries' => (array) $this->get_param('icon_libraries', []), |
| 1167 |
'colors' => (array) $this->get_param('colors', []), |
| 1168 |
]; |
| 1169 |
|
| 1170 |
$extra_headers = [ |
| 1171 |
'x-templately-requested-platform' => Helper::get_requested_platform(), |
| 1172 |
]; |
| 1173 |
|
| 1174 |
// GET, not POST — and not a style choice: `v2/generate-brand-kit` answers |
| 1175 |
// `Allow: GET,HEAD`, so a POST is rejected with 405 before the cloud ever reads the |
| 1176 |
// body, and every brand kit failed. (Its sibling `v2/generate-tagline` DOES allow |
| 1177 |
// POST, which is why the two look inconsistent here; they are, on the server.) |
| 1178 |
// The fields travel as query params — `make_api_get_request` encodes them, arrays |
| 1179 |
// included — and none of them is a secret: the credential rides the Authorization |
| 1180 |
// header, as on every other call. |
| 1181 |
$response = Helper::make_api_get_request('v2/generate-brand-kit', $body_data, $extra_headers, 30); |
| 1182 |
|
| 1183 |
if (is_wp_error($response)) { |
| 1184 |
return $this->error( |
| 1185 |
'api_request_failed', |
| 1186 |
__('Failed to generate brand kit.', 'templately'), |
| 1187 |
'generate_brand_kit', |
| 1188 |
500, |
| 1189 |
['error_detail' => $response->get_error_message()] |
| 1190 |
); |
| 1191 |
} |
| 1192 |
|
| 1193 |
$response_code = wp_remote_retrieve_response_code($response); |
| 1194 |
$data = json_decode(wp_remote_retrieve_body($response), true); |
| 1195 |
|
| 1196 |
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($data)) { |
| 1197 |
return $this->error( |
| 1198 |
'invalid_response', |
| 1199 |
__('Invalid response from API.', 'templately'), |
| 1200 |
'generate_brand_kit', |
| 1201 |
500 |
| 1202 |
); |
| 1203 |
} |
| 1204 |
|
| 1205 |
if ($response_code !== 200) { |
| 1206 |
$message = ! empty($data['message']) |
| 1207 |
? $data['message'] |
| 1208 |
: __('Something went wrong. Please try again or contact support.', 'templately'); |
| 1209 |
|
| 1210 |
return $this->error('api_response_error', $message, 'generate_brand_kit', $response_code); |
| 1211 |
} |
| 1212 |
|
| 1213 |
// Same rule as {@see generate_tagline()}: a body-level `status:'error'` on an HTTP 200 |
| 1214 |
// is a failure, not a payload. Insufficient credit arrives this way. |
| 1215 |
if (isset($data['status']) && 'error' === $data['status']) { |
| 1216 |
$message = ! empty($data['message']) |
| 1217 |
? $data['message'] |
| 1218 |
: __('Failed to generate brand kit.', 'templately'); |
| 1219 |
|
| 1220 |
return $this->error('brand_kit_generation_failed', $message, 'generate_brand_kit', 400); |
| 1221 |
} |
| 1222 |
|
| 1223 |
return $data; |
| 1224 |
} |
| 1225 |
|
| 1226 |
/** |
| 1227 |
* Fetch a chatbot conversation by ID from the external Templately chatbot API. |
| 1228 |
* |
| 1229 |
* Used to resume a conversation that began on templately.dev when the user |
| 1230 |
* is redirected into the plugin with ?process=ai&chat={uuid}. |
| 1231 |
* |
| 1232 |
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error. |
| 1233 |
*/ |
| 1234 |
public function get_chatbot_conversation() { |
| 1235 |
$chat = $this->get_param('chat'); |
| 1236 |
|
| 1237 |
if (empty($chat)) { |
| 1238 |
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-conversation', 400); |
| 1239 |
} |
| 1240 |
|
| 1241 |
return $this->chatbot_request('GET', "v2/chatbot/conversation/{$chat}", 'ai-content/chatbot-conversation', [ |
| 1242 |
'transport_message' => __('Failed to fetch conversation.', 'templately'), |
| 1243 |
'error_code' => 'conversation_unavailable', |
| 1244 |
'error_message' => __('Could not retrieve the conversation.', 'templately'), |
| 1245 |
]); |
| 1246 |
} |
| 1247 |
|
| 1248 |
/** |
| 1249 |
* The account's AI-generated sites, paginated — the list behind "My AI Sites". |
| 1250 |
* |
| 1251 |
* Plural sibling of {@see get_chatbot_conversation()}: that one fetches a |
| 1252 |
* single conversation by id, this one enumerates them so a user can come back |
| 1253 |
* to a site they generated earlier and import it again. |
| 1254 |
* |
| 1255 |
* The upstream page size is clamped rather than trusted. `per_page` reaches |
| 1256 |
* this from a query string, and an unbounded value turns one list render into |
| 1257 |
* an arbitrarily large upstream fetch — the pagination exists precisely |
| 1258 |
* because an account can accumulate a lot of these. |
| 1259 |
* |
| 1260 |
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error. |
| 1261 |
*/ |
| 1262 |
public function get_chatbot_conversations() { |
| 1263 |
$page = max(1, (int) $this->get_param('page', 1, 'absint')); |
| 1264 |
$per_page = min(100, max(1, (int) $this->get_param('per_page', 10, 'absint'))); |
| 1265 |
|
| 1266 |
return $this->chatbot_request('GET', 'v2/chatbot/conversations', 'ai-content/chatbot-conversations', [ |
| 1267 |
'body' => [ |
| 1268 |
'page' => $page, |
| 1269 |
'per_page' => $per_page, |
| 1270 |
], |
| 1271 |
'transport_message' => __('Failed to fetch your AI sites.', 'templately'), |
| 1272 |
'error_code' => 'conversations_unavailable', |
| 1273 |
'error_message' => __('Could not retrieve your AI sites.', 'templately'), |
| 1274 |
]); |
| 1275 |
} |
| 1276 |
|
| 1277 |
/** |
| 1278 |
* THE single path to the chatbot API. Every call goes through here. |
| 1279 |
* |
| 1280 |
* This exists because the validation could not be left to call sites. Four |
| 1281 |
* handlers each hand-rolled the same twenty lines — transport check, HTTP |
| 1282 |
* status check, decode, envelope check — and every one of them stopped at |
| 1283 |
* "does `status` EXIST", then returned the body. The chatbot API signals |
| 1284 |
* application-level failure INSIDE an HTTP 200 as `{status:'error', message}`, |
| 1285 |
* so all four forwarded failures that the 043 envelope then stamped |
| 1286 |
* `success: true`. Downstream, the caller reads a field the error body does |
| 1287 |
* not carry, gets nothing, and renders an empty state with no error and no |
| 1288 |
* retry — the "an error is never an empty array" failure includes/CLAUDE.md |
| 1289 |
* warns about. |
| 1290 |
* |
| 1291 |
* Guarding each site individually fixed those four and left the NEXT handler |
| 1292 |
* free to inherit the bug by simply not knowing about the guard. Owning the |
| 1293 |
* whole sequence here makes that impossible: there is no way to call the |
| 1294 |
* chatbot API without the check, because there is no other way to call it. |
| 1295 |
* |
| 1296 |
* The disconnected pre-flight lives here for the same reason. Only |
| 1297 |
* {@see get_chatbot_conversation()} carried it inline, so the other four |
| 1298 |
* handlers made the doomed upstream call anyway and answered with a generic |
| 1299 |
* auth error that named no remedy. A disconnected site cannot authenticate |
| 1300 |
* upstream, full stop — so the check belongs to the seam, not to whichever |
| 1301 |
* handler remembered it. |
| 1302 |
* |
| 1303 |
* Inside this seam an error body is ALWAYS a failure — there is no opt-out. |
| 1304 |
* The callers that legitimately render an error body as a partial outcome |
| 1305 |
* ({@see modify_content()} via `Helper::api_post(['unwrap' => false])`, |
| 1306 |
* {@see generate_tagline()} with its inline guard) are different route |
| 1307 |
* families and sit outside this seam by design. |
| 1308 |
* |
| 1309 |
* @param string $method GET|POST. |
| 1310 |
* @param string $path Upstream path, e.g. `v2/chatbot/generated/{id}`. |
| 1311 |
* @param string $endpoint Endpoint label carried on error payloads. |
| 1312 |
* @param array $opts body, timeout, with_api_key, transport_message, |
| 1313 |
* error_code, error_message. |
| 1314 |
* @return array|\WP_Error Decoded body on success; WP_Error on ANY failure. |
| 1315 |
*/ |
| 1316 |
private function chatbot_request(string $method, string $path, string $endpoint, array $opts = []) { |
| 1317 |
$opts = wp_parse_args($opts, [ |
| 1318 |
'body' => [], |
| 1319 |
'timeout' => 30, |
| 1320 |
'with_api_key' => false, |
| 1321 |
'transport_message' => __('The request could not be completed.', 'templately'), |
| 1322 |
'error_code' => 'upstream_error', |
| 1323 |
'error_message' => __('The request could not be completed.', 'templately'), |
| 1324 |
]); |
| 1325 |
|
| 1326 |
// Pre-flight the connection, mirroring the FSI handlers |
| 1327 |
// (Concerns\HandlesSession::import_settings, Concerns\RunsImport). |
| 1328 |
$user = $this->options()->get('user'); |
| 1329 |
|
| 1330 |
if (!empty($user['is_disconnected'])) { |
| 1331 |
return $this->error( |
| 1332 |
ErrorCode::SITE_DISCONNECTED, |
| 1333 |
__('Your site connection is disconnected. Please migrate your connection first.', 'templately'), |
| 1334 |
$endpoint, |
| 1335 |
403 |
| 1336 |
); |
| 1337 |
} |
| 1338 |
|
| 1339 |
$headers = ['Accept' => 'application/json']; |
| 1340 |
|
| 1341 |
// Some chatbot routes are gated on the key header rather than the bearer. |
| 1342 |
if ($opts['with_api_key']) { |
| 1343 |
$headers['X-Templately-Apikey'] = $this->api_key; |
| 1344 |
} |
| 1345 |
|
| 1346 |
$response = ('POST' === strtoupper($method)) |
| 1347 |
? Helper::make_api_post_request($path, $opts['body'], $headers, $opts['timeout']) |
| 1348 |
: Helper::make_api_get_request($path, $opts['body'], $headers, $opts['timeout']); |
| 1349 |
|
| 1350 |
if (is_wp_error($response)) { |
| 1351 |
return $this->error( |
| 1352 |
'request_failed', |
| 1353 |
$opts['transport_message'], |
| 1354 |
$endpoint, |
| 1355 |
500, |
| 1356 |
['error_detail' => $response->get_error_message()] |
| 1357 |
); |
| 1358 |
} |
| 1359 |
|
| 1360 |
$code = wp_remote_retrieve_response_code($response); |
| 1361 |
$data = json_decode(wp_remote_retrieve_body($response), true); |
| 1362 |
|
| 1363 |
if (200 !== $code) { |
| 1364 |
$message = (is_array($data) && !empty($data['message'])) |
| 1365 |
? $data['message'] |
| 1366 |
: sprintf( |
| 1367 |
/* translators: %d: HTTP status code returned by the API. */ |
| 1368 |
__('API returned HTTP %d error.', 'templately'), |
| 1369 |
$code |
| 1370 |
); |
| 1371 |
|
| 1372 |
return $this->error('api_http_error', $message, $endpoint, $code ?: 500); |
| 1373 |
} |
| 1374 |
|
| 1375 |
if (!is_array($data) || !isset($data['status'])) { |
| 1376 |
return $this->error('invalid_response', __('Invalid response.', 'templately'), $endpoint, 500); |
| 1377 |
} |
| 1378 |
|
| 1379 |
if ('error' === $data['status']) { |
| 1380 |
$message = !empty($data['message']) ? $data['message'] : $opts['error_message']; |
| 1381 |
|
| 1382 |
return $this->error($opts['error_code'], $message, $endpoint, 400); |
| 1383 |
} |
| 1384 |
|
| 1385 |
return $data; |
| 1386 |
} |
| 1387 |
|
| 1388 |
/** |
| 1389 |
* Fetch the server-side generated content for a chatbot conversation (Phase 2). |
| 1390 |
* |
| 1391 |
* In Phase 2 the AI content is generated on the backend. This proxy mirrors |
| 1392 |
* {@see get_chatbot_conversation()} and returns the already-generated page |
| 1393 |
* content, customization data and signed logo URL so the plugin can run a |
| 1394 |
* thin import without triggering generation or the customizer locally. |
| 1395 |
* |
| 1396 |
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error. |
| 1397 |
*/ |
| 1398 |
public function get_chatbot_generated() { |
| 1399 |
$chat = $this->get_param('chat'); |
| 1400 |
|
| 1401 |
if (empty($chat)) { |
| 1402 |
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-generated', 400); |
| 1403 |
} |
| 1404 |
|
| 1405 |
// Validation happens inside chatbot_request(), i.e. BEFORE the transient |
| 1406 |
// write below — an error body must never be cached. Caching one would |
| 1407 |
// replay the failure to chatbot-import-prepare for the whole TTL, so a |
| 1408 |
// TRANSIENT upstream error would look like a persistent one and the thin |
| 1409 |
// import would keep failing after the cause had cleared. |
| 1410 |
$data = $this->chatbot_request('GET', "v2/chatbot/generated/{$chat}", 'ai-content/chatbot-generated', [ |
| 1411 |
'transport_message' => __('Failed to fetch generated content.', 'templately'), |
| 1412 |
'error_code' => 'generated_unavailable', |
| 1413 |
'error_message' => __('Could not retrieve the generated content.', 'templately'), |
| 1414 |
]); |
| 1415 |
|
| 1416 |
if (is_wp_error($data)) { |
| 1417 |
return $data; |
| 1418 |
} |
| 1419 |
|
| 1420 |
// The direct-import handoff reads this endpoint and then immediately calls |
| 1421 |
// chatbot-import-prepare, which pulls the very same (large) bundle off GCP |
| 1422 |
// seconds later. Park it so prepare can reuse it instead of paying for a |
| 1423 |
// second identical transfer. |
| 1424 |
Database::set_transient(self::GENERATED_CACHE_KEY . $chat, $data, self::GENERATED_CACHE_TTL); |
| 1425 |
|
| 1426 |
return $data; |
| 1427 |
} |
| 1428 |
|
| 1429 |
/** |
| 1430 |
* Does a `v2/chatbot/generated` bundle already carry every expected page? |
| 1431 |
* |
| 1432 |
* A page counts as present when it is in `templates` (string or int key, the |
| 1433 |
* upstream is inconsistent) or listed in `skipped_pages` — a skipped page is |
| 1434 |
* never coming, so waiting on it would hang the poll until it timed out. |
| 1435 |
* |
| 1436 |
* @param array $data Decoded `{ status, data }` bundle. |
| 1437 |
* @param array $expected_ids Flattened page ids, as strings. |
| 1438 |
* @return bool |
| 1439 |
*/ |
| 1440 |
private function is_generated_bundle_complete($data, $expected_ids) { |
| 1441 |
$generated = isset($data['data']) && is_array($data['data']) ? $data['data'] : []; |
| 1442 |
|
| 1443 |
// Never reuse a bundle the user is no longer allowed to import. |
| 1444 |
if (isset($generated['can_import']) && ! $generated['can_import']) { |
| 1445 |
return false; |
| 1446 |
} |
| 1447 |
|
| 1448 |
$templates = isset($generated['templates']) && is_array($generated['templates']) ? $generated['templates'] : []; |
| 1449 |
$skipped = isset($generated['skipped_pages']) && is_array($generated['skipped_pages']) ? array_map('strval', $generated['skipped_pages']) : []; |
| 1450 |
|
| 1451 |
if (empty($templates) && empty($skipped)) { |
| 1452 |
return false; |
| 1453 |
} |
| 1454 |
|
| 1455 |
foreach ($expected_ids as $id) { |
| 1456 |
if (array_key_exists($id, $templates) || array_key_exists((int) $id, $templates) || in_array($id, $skipped, true)) { |
| 1457 |
continue; |
| 1458 |
} |
| 1459 |
return false; |
| 1460 |
} |
| 1461 |
|
| 1462 |
return true; |
| 1463 |
} |
| 1464 |
|
| 1465 |
/** |
| 1466 |
* Persist edited detected-info back to the chatbot conversation (Phase 2). |
| 1467 |
* |
| 1468 |
* Mirrors {@see get_chatbot_conversation()} / {@see get_chatbot_generated()} |
| 1469 |
* but forwards a POST. When the user edits the detected-info card in the |
| 1470 |
* sidebar, the plugin proxies the corrected values to the backend so a later |
| 1471 |
* replay reflects them. The backend route is gated by the X-Templately-Apikey |
| 1472 |
* header, so it is supplied explicitly here. |
| 1473 |
* |
| 1474 |
* Expected JSON body: { chat, detected_info: { ...fields } } |
| 1475 |
* |
| 1476 |
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error. |
| 1477 |
*/ |
| 1478 |
public function update_chatbot_detected_info() { |
| 1479 |
$chat = $this->get_param('chat'); |
| 1480 |
$detected_info = $this->get_param('detected_info', [], null); |
| 1481 |
|
| 1482 |
if (empty($chat)) { |
| 1483 |
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-detected-info', 400); |
| 1484 |
} |
| 1485 |
|
| 1486 |
// detected_info may arrive as a JSON string when sent via FormData. |
| 1487 |
if (is_string($detected_info)) { |
| 1488 |
$decoded = json_decode($detected_info, true); |
| 1489 |
$detected_info = is_array($decoded) ? $decoded : []; |
| 1490 |
} |
| 1491 |
if (!is_array($detected_info)) { |
| 1492 |
$detected_info = []; |
| 1493 |
} |
| 1494 |
|
| 1495 |
// The caller uses templatelyApiOrThrow, which throws on `error` but NOT on a |
| 1496 |
// body-level `status:'error'` — so an unguarded failure meant the user's |
| 1497 |
// edit silently did not persist and a later replay used the stale values. |
| 1498 |
return $this->chatbot_request('POST', "v2/chatbot/conversation/{$chat}/detected-info", 'ai-content/chatbot-detected-info', [ |
| 1499 |
'body' => ['detected_info' => $detected_info], |
| 1500 |
'with_api_key' => true, |
| 1501 |
'transport_message' => __('Failed to update detected info.', 'templately'), |
| 1502 |
'error_code' => 'detected_info_not_saved', |
| 1503 |
'error_message' => __('Could not save your changes.', 'templately'), |
| 1504 |
]); |
| 1505 |
} |
| 1506 |
|
| 1507 |
/** |
| 1508 |
* Report a completed import back to templately.dev (Phase 2 import-once gate). |
| 1509 |
* |
| 1510 |
* Once the plugin finishes importing the generated content for a conversation, |
| 1511 |
* it calls this so the backend flips the conversation status to `imported`. |
| 1512 |
* Subsequent pulls then return `already_imported = true`, and the web/plugin |
| 1513 |
* UIs refuse a second import. |
| 1514 |
* |
| 1515 |
* @return array|\WP_Error |
| 1516 |
*/ |
| 1517 |
public function mark_chatbot_imported() { |
| 1518 |
$chat = $this->get_param('chat'); |
| 1519 |
|
| 1520 |
if (empty($chat)) { |
| 1521 |
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-mark-imported', 400); |
| 1522 |
} |
| 1523 |
|
| 1524 |
// A GATE, not a read: this flips the conversation to `imported` so a second |
| 1525 |
// import is refused. Forwarding an upstream failure as success made |
| 1526 |
// markChatbotImported() return true when nothing had been recorded — the |
| 1527 |
// import-once gate failing OPEN, the one direction it must not fail. The |
| 1528 |
// caller already branches on `error`, so it now correctly returns false. |
| 1529 |
return $this->chatbot_request('POST', "v2/chatbot/conversation/{$chat}/imported", 'ai-content/chatbot-mark-imported', [ |
| 1530 |
'with_api_key' => true, |
| 1531 |
'transport_message' => __('Failed to record import.', 'templately'), |
| 1532 |
'error_code' => 'mark_imported_failed', |
| 1533 |
'error_message' => __('Could not record the import.', 'templately'), |
| 1534 |
]); |
| 1535 |
} |
| 1536 |
|
| 1537 |
/** |
| 1538 |
* Resolve the conversation answers to store on a chat-driven process. |
| 1539 |
* |
| 1540 |
* Prefers what the client sent (the sidebar holds the detected info the user |
| 1541 |
* may have just edited), then what was already stored for this process (so a |
| 1542 |
* repeat call costs nothing), and only then pulls the conversation from the |
| 1543 |
* cloud — which is the path the `?process=import` landing takes, since it |
| 1544 |
* never opens the sidebar and so has no answers to send. |
| 1545 |
* |
| 1546 |
* A failure here must never break the import: it returns an empty array and |
| 1547 |
* the process is stored exactly as before. |
| 1548 |
* |
| 1549 |
* @param string $chat Conversation uuid. |
| 1550 |
* @param mixed $detected_info Raw `detected_info` sent by the client. |
| 1551 |
* @param array $ai_process_data All stored process data. |
| 1552 |
* @param string $process_id This process id. |
| 1553 |
* @return array<string,string> Map of conversation step key => value. |
| 1554 |
*/ |
| 1555 |
private function resolve_chat_conversation_fields($chat, $detected_info, $ai_process_data, $process_id) { |
| 1556 |
$mapped = AIUtils::map_chat_detected_info($detected_info); |
| 1557 |
if (!empty($mapped)) { |
| 1558 |
return $mapped; |
| 1559 |
} |
| 1560 |
|
| 1561 |
// Already resolved on an earlier call for this process — reuse it. |
| 1562 |
if (isset($ai_process_data[$process_id]) && AIUtils::has_conversation_data($ai_process_data[$process_id])) { |
| 1563 |
return AIUtils::map_chat_detected_info( |
| 1564 |
array_intersect_key($ai_process_data[$process_id], array_flip(AIUtils::CONVERSATION_FIELDS)) |
| 1565 |
); |
| 1566 |
} |
| 1567 |
|
| 1568 |
$response = Helper::make_api_get_request("v2/chatbot/conversation/{$chat}", [], ['Accept' => 'application/json'], 30); |
| 1569 |
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { |
| 1570 |
Helper::log(sprintf('chatbot_import_prepare[%s] conversation fetch failed — process stored without answers', $chat), 'ai-import', 'warning'); |
| 1571 |
return []; |
| 1572 |
} |
| 1573 |
|
| 1574 |
$data = json_decode(wp_remote_retrieve_body($response), true); |
| 1575 |
if (!is_array($data) || empty($data['data']['detected_info'])) { |
| 1576 |
return []; |
| 1577 |
} |
| 1578 |
|
| 1579 |
return AIUtils::map_chat_detected_info($data['data']['detected_info']); |
| 1580 |
} |
| 1581 |
|
| 1582 |
/** |
| 1583 |
* Thin importer prepare step (Phase 2). |
| 1584 |
* |
| 1585 |
* Given a chat uuid and a session that has already been created and had its |
| 1586 |
* pack downloaded (via the existing templately_pack_create_session_and_download |
| 1587 |
* AJAX flow), this: |
| 1588 |
* 1. Registers AI process data (including `chat_id`) so ai_get_json()/ |
| 1589 |
* validation keep working AND so the Finalizer's ChatAIContentProvider |
| 1590 |
* can claim this process. |
| 1591 |
* 2. Fetches the backend-generated page content for the conversation. |
| 1592 |
* 3. Writes whatever pages are ALREADY generated to the same .ai.json |
| 1593 |
* location the legacy flow uses (via AIUtils::save_template_to_file). |
| 1594 |
* 4. Downloads the signed logo URL into the WP media library (Utils::upload_logo). |
| 1595 |
* |
| 1596 |
* This endpoint NEVER waits for generation to complete. Pages still being |
| 1597 |
* generated are reported in `missing` and are pulled on demand — and waited |
| 1598 |
* for — by the Finalizer via AIContentResolver. Previously this held the |
| 1599 |
* client in a 3s poll loop until every page was ready, which delayed the |
| 1600 |
* start of the import by minutes for no benefit. |
| 1601 |
* |
| 1602 |
* It returns the resolved customization data, logo attachment and the |
| 1603 |
* process_id so the React app can build the settings FormData and run the |
| 1604 |
* existing import. No generation or local customizer is involved. |
| 1605 |
* |
| 1606 |
* Expected JSON body: { chat, session_id, ai_page_ids: { 'content/page': [...], templates: [...] }, |
| 1607 |
* pack_id?, platform?, detected_info? } |
| 1608 |
* |
| 1609 |
* @return array|\WP_Error |
| 1610 |
*/ |
| 1611 |
public function chatbot_import_prepare() { |
| 1612 |
add_filter('wp_redirect', '__return_false', 999); |
| 1613 |
set_time_limit(3 * MINUTE_IN_SECONDS); |
| 1614 |
|
| 1615 |
$handler_started = microtime(true); |
| 1616 |
|
| 1617 |
$chat = $this->get_param('chat'); |
| 1618 |
$session_id = $this->get_param('session_id'); |
| 1619 |
$ai_page_ids = $this->get_param('ai_page_ids', [], null); |
| 1620 |
// Conversation context, so reopening "Build with AI" later can resume this |
| 1621 |
// app-end session instead of starting from scratch. `detected_info` is |
| 1622 |
// sanitized field-by-field in AIUtils::map_chat_detected_info(). |
| 1623 |
$pack_id = $this->get_param('pack_id', 0, 'absint'); |
| 1624 |
$platform = $this->get_param('platform'); |
| 1625 |
$detected_info = $this->get_param('detected_info', [], null); |
| 1626 |
|
| 1627 |
if (empty($chat)) { |
| 1628 |
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-import-prepare', 400); |
| 1629 |
} |
| 1630 |
|
| 1631 |
if (empty($session_id)) { |
| 1632 |
return $this->error('invalid_session_id', __('Invalid session ID.', 'templately'), 'ai-content/chatbot-import-prepare', 400); |
| 1633 |
} |
| 1634 |
|
| 1635 |
// Security: sanitize the session id before it is used to build file paths. |
| 1636 |
$session_id = AIUtils::sanitize_path_component($session_id, 'session_id'); |
| 1637 |
if (is_wp_error($session_id)) { |
| 1638 |
return $this->error('invalid_session_id', $session_id->get_error_message(), 'ai-content/chatbot-import-prepare', 400); |
| 1639 |
} |
| 1640 |
|
| 1641 |
// ai_page_ids may arrive as a JSON string when sent via FormData, and with |
| 1642 |
// scalar / comma-separated group values — normalize to the canonical |
| 1643 |
// `type/sub_type => ['id',...]` shape before anything indexes into it. |
| 1644 |
$ai_page_ids = AIUtils::normalize_ai_page_ids($ai_page_ids); |
| 1645 |
if (empty($ai_page_ids)) { |
| 1646 |
return $this->error('invalid_ai_page_ids', __('Invalid AI page IDs.', 'templately'), 'ai-content/chatbot-import-prepare', 400); |
| 1647 |
} |
| 1648 |
|
| 1649 |
// Expected page ids (flattened) — the client may redirect to customization |
| 1650 |
// as soon as the home/header/footer are ready, so by import time some pages |
| 1651 |
// can still be generating. |
| 1652 |
$expected_ids = AIUtils::flatten_ai_page_ids($ai_page_ids); |
| 1653 |
|
| 1654 |
$cache_key = self::GENERATED_CACHE_KEY . $chat; |
| 1655 |
|
| 1656 |
// This endpoint NEVER waits for completeness. The import starts as soon as |
| 1657 |
// the session exists; whatever pages are already generated are written |
| 1658 |
// here as a warm start, and any page still generating is pulled on demand |
| 1659 |
// by the Finalizer (FullSiteImport\Utils\AIContentResolver + |
| 1660 |
// Providers\ChatAIContentProvider). |
| 1661 |
// |
| 1662 |
// Reuse the bundle chatbot-generated just parked, but ONLY when it already |
| 1663 |
// holds every expected page — a complete bundle cannot become less |
| 1664 |
// complete, whereas an incomplete one has to be re-pulled to pick up the |
| 1665 |
// pages that have since finished. On the common handoff (generation |
| 1666 |
// finished long before the user landed here) this removes an entire |
| 1667 |
// duplicate transfer of every page's block JSON. |
| 1668 |
$pull_started = microtime(true); |
| 1669 |
$pull_duration = 0; |
| 1670 |
$data = Database::get_transient($cache_key); |
| 1671 |
$from_cache = is_array($data) && $this->is_generated_bundle_complete($data, $expected_ids); |
| 1672 |
|
| 1673 |
if (! $from_cache) { |
| 1674 |
// Single pull — no server-side sleep/retry. Routed through the shared |
| 1675 |
// seam so this endpoint cannot drift from the others on what counts as |
| 1676 |
// a failure; it was the last hand-rolled copy of that sequence. |
| 1677 |
$data = $this->chatbot_request('GET', "v2/chatbot/generated/{$chat}", 'ai-content/chatbot-import-prepare', [ |
| 1678 |
'timeout' => 2 * MINUTE_IN_SECONDS, |
| 1679 |
'transport_message' => __('Failed to fetch generated content.', 'templately'), |
| 1680 |
'error_code' => 'generated_unavailable', |
| 1681 |
'error_message' => __('Could not retrieve the generated content.', 'templately'), |
| 1682 |
]); |
| 1683 |
$pull_duration = microtime(true) - $pull_started; |
| 1684 |
|
| 1685 |
// A FAILED pull is not an empty one. Before this, an upstream |
| 1686 |
// `status:'error'` on an HTTP 200 passed the shape check (`status` was |
| 1687 |
// present), left `$generated` empty, and fell through the whole handler |
| 1688 |
// to return `status:'success'` with saved=0 and every page "missing" — |
| 1689 |
// so a hard upstream failure was indistinguishable from the legitimate |
| 1690 |
// "nothing generated yet, the Finalizer will pull on demand" case, and |
| 1691 |
// the import began against a process with no content behind it. |
| 1692 |
if (is_wp_error($data)) { |
| 1693 |
Helper::log( |
| 1694 |
sprintf('chatbot_import_prepare[%s] pull failed after %.2fs: %s', $chat, $pull_duration, $data->get_error_message()), |
| 1695 |
'ai-import', |
| 1696 |
'error' |
| 1697 |
); |
| 1698 |
|
| 1699 |
return $data; |
| 1700 |
} |
| 1701 |
|
| 1702 |
// Park a complete bundle for the credits re-read on the success screen. |
| 1703 |
if ($this->is_generated_bundle_complete($data, $expected_ids)) { |
| 1704 |
Database::set_transient($cache_key, $data, self::GENERATED_CACHE_TTL); |
| 1705 |
} |
| 1706 |
} else { |
| 1707 |
Helper::log(sprintf('chatbot_import_prepare[%s] reused cached bundle (no upstream pull)', $chat), 'ai-import', 'info'); |
| 1708 |
} |
| 1709 |
|
| 1710 |
$generated = isset($data['data']) && is_array($data['data']) ? $data['data'] : []; |
| 1711 |
|
| 1712 |
// Access gate: the backend blocks a free user past their 7-day window. |
| 1713 |
if (isset($generated['can_import']) && ! $generated['can_import']) { |
| 1714 |
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); |
| 1715 |
} |
| 1716 |
|
| 1717 |
$templates = isset($generated['templates']) && is_array($generated['templates']) ? $generated['templates'] : []; |
| 1718 |
|
| 1719 |
// Pages the backend skipped (empty source JSON) or failed to generate. |
| 1720 |
// These will never appear in `templates`, so they must not be treated |
| 1721 |
// as "still generating" — without this, one skipped page keeps the poll |
| 1722 |
// pending until it times out. |
| 1723 |
$skipped_pages = isset($generated['skipped_pages']) && is_array($generated['skipped_pages']) ? array_map('strval', $generated['skipped_pages']) : []; |
| 1724 |
$skipped_expected = array_values(array_intersect($expected_ids, $skipped_pages)); |
| 1725 |
|
| 1726 |
// Which expected pages are still missing from the bundle? |
| 1727 |
$missing = array_values(array_filter($expected_ids, function ($id) use ($templates, $skipped_pages) { |
| 1728 |
return !array_key_exists($id, $templates) && !array_key_exists((int) $id, $templates) && !in_array($id, $skipped_pages, true); |
| 1729 |
})); |
| 1730 |
|
| 1731 |
$ready = array_values(array_diff($expected_ids, $missing)); |
| 1732 |
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'); |
| 1733 |
|
| 1734 |
// Derive a process_id for this chat-driven import and register process data |
| 1735 |
// so the existing validation/ai_get_json paths keep functioning. |
| 1736 |
$process_id = 'chat-' . $session_id; |
| 1737 |
$user = $this->utils('options')->get('user'); |
| 1738 |
|
| 1739 |
$ai_process_data = AIUtils::get_ai_process_data(); |
| 1740 |
$record = [ |
| 1741 |
'process_id' => $process_id, |
| 1742 |
'session_id' => $session_id, |
| 1743 |
'ai_page_ids' => $ai_page_ids, |
| 1744 |
'api_key' => $this->api_key, |
| 1745 |
'user_id' => isset($user['id']) ? $user['id'] : null, |
| 1746 |
'chat_id' => $chat, |
| 1747 |
]; |
| 1748 |
|
| 1749 |
if (!empty($pack_id)) { |
| 1750 |
$record['pack_id'] = $pack_id; |
| 1751 |
} |
| 1752 |
if (!empty($platform)) { |
| 1753 |
$record['platform'] = $platform; |
| 1754 |
} |
| 1755 |
|
| 1756 |
// Persist the app-end answers exactly as an in-plugin conversation stores |
| 1757 |
// them, so reopening "Build with AI" resumes this session instead of |
| 1758 |
// starting over. Without this the record holds no answers at all and the |
| 1759 |
// sidebar has nothing to restore. |
| 1760 |
$conversation = AIUtils::build_conversation_fields( |
| 1761 |
$this->resolve_chat_conversation_fields($chat, $detected_info, $ai_process_data, $process_id) |
| 1762 |
); |
| 1763 |
if (!empty($conversation)) { |
| 1764 |
$record = array_merge($record, $conversation); |
| 1765 |
} |
| 1766 |
|
| 1767 |
$ai_process_data[$process_id] = $record; |
| 1768 |
AIUtils::update_ai_process_data($ai_process_data); |
| 1769 |
|
| 1770 |
// Persist each generated page to its .ai.json location for the import runners. |
| 1771 |
// |
| 1772 |
// Every page present in THIS pull is written immediately, even when others |
| 1773 |
// are still generating. Holding the writes back until the whole set was |
| 1774 |
// ready meant a single slow page threw away the full bundle on every poll |
| 1775 |
// — dozens of multi-hundred-KB pulls (all of `templates`, straight off GCP) |
| 1776 |
// discarded to save nothing. Writing as we go also lets the Finalizer |
| 1777 |
// runner finalize the pages that ARE ready instead of blocking on all of |
| 1778 |
// them. Already-written pages are skipped, so a re-poll is cheap. |
| 1779 |
$save_started = microtime(true); |
| 1780 |
$saved_count = 0; |
| 1781 |
$errors = []; |
| 1782 |
$processed_pages = get_option('templately_ai_processed_pages', []); |
| 1783 |
$already_saved = isset($processed_pages[$process_id]['pages']) ? $processed_pages[$process_id]['pages'] : []; |
| 1784 |
foreach ($templates as $content_id => $template) { |
| 1785 |
if (empty($template)) { |
| 1786 |
continue; |
| 1787 |
} |
| 1788 |
|
| 1789 |
if (array_key_exists((string) $content_id, $already_saved)) { |
| 1790 |
$saved_count++; |
| 1791 |
continue; |
| 1792 |
} |
| 1793 |
|
| 1794 |
// The runners read JSON strings; normalize arrays/objects to a string. |
| 1795 |
$template_payload = is_string($template) ? $template : wp_json_encode($template); |
| 1796 |
|
| 1797 |
$result = AIUtils::save_template_to_file( |
| 1798 |
$process_id, |
| 1799 |
$session_id, |
| 1800 |
$content_id, |
| 1801 |
$template_payload, |
| 1802 |
$ai_page_ids, |
| 1803 |
false |
| 1804 |
); |
| 1805 |
|
| 1806 |
if (is_wp_error($result)) { |
| 1807 |
$errors[$content_id] = $result->get_error_message(); |
| 1808 |
continue; |
| 1809 |
} |
| 1810 |
if (isset($result['status']) && $result['status'] === 'success') { |
| 1811 |
$saved_count++; |
| 1812 |
} else { |
| 1813 |
$errors[$content_id] = isset($result['message']) ? $result['message'] : 'unknown'; |
| 1814 |
} |
| 1815 |
} |
| 1816 |
|
| 1817 |
// Write each backend-skipped page as an explicit `{"isSkipped": true}` |
| 1818 |
// marker (same shape the legacy per-page callback wrote) so the import |
| 1819 |
// runners fall back to the pack's default content instead of treating |
| 1820 |
// the page as missing. |
| 1821 |
$skipped_saved = []; |
| 1822 |
foreach ($skipped_expected as $skipped_id) { |
| 1823 |
if (array_key_exists($skipped_id, $templates) || array_key_exists((int) $skipped_id, $templates)) { |
| 1824 |
continue; |
| 1825 |
} |
| 1826 |
|
| 1827 |
if (array_key_exists((string) $skipped_id, $already_saved)) { |
| 1828 |
$skipped_saved[] = $skipped_id; |
| 1829 |
continue; |
| 1830 |
} |
| 1831 |
|
| 1832 |
$result = AIUtils::save_template_to_file( |
| 1833 |
$process_id, |
| 1834 |
$session_id, |
| 1835 |
$skipped_id, |
| 1836 |
'', |
| 1837 |
$ai_page_ids, |
| 1838 |
true |
| 1839 |
); |
| 1840 |
|
| 1841 |
if (is_wp_error($result)) { |
| 1842 |
$errors[$skipped_id] = $result->get_error_message(); |
| 1843 |
continue; |
| 1844 |
} |
| 1845 |
if (isset($result['status']) && $result['status'] === 'success') { |
| 1846 |
$skipped_saved[] = $skipped_id; |
| 1847 |
} else { |
| 1848 |
$errors[$skipped_id] = isset($result['message']) ? $result['message'] : 'unknown'; |
| 1849 |
} |
| 1850 |
} |
| 1851 |
|
| 1852 |
$save_duration = microtime(true) - $save_started; |
| 1853 |
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'); |
| 1854 |
|
| 1855 |
// NOTE: there is deliberately NO `pending` return here any more. Pages that |
| 1856 |
// are still generating come back in `missing` and are pulled on demand — |
| 1857 |
// and waited for — by the Finalizer. Returning `pending` made the client |
| 1858 |
// poll for minutes before the import could even start. |
| 1859 |
// |
| 1860 |
// NOT an error when nothing was saved: with the wait deferred to the |
| 1861 |
// Finalizer it is legitimate for zero pages to be ready at import start. |
| 1862 |
// Only a genuine write failure (something was ready but every save |
| 1863 |
// errored) is fatal. |
| 1864 |
if ($saved_count === 0 && empty($skipped_saved) && !empty($errors)) { |
| 1865 |
return $this->error('save_failed', __('Failed to save generated content.', 'templately'), 'ai-content/chatbot-import-prepare', 500, ['errors' => $errors]); |
| 1866 |
} |
| 1867 |
|
| 1868 |
Helper::log(sprintf('chatbot_import_prepare[%s] returning: pages=%d missing=%d pull=%.2fs', $chat, count($templates), count($missing), $pull_duration), 'ai-import', 'info'); |
| 1869 |
|
| 1870 |
// Import the logo into the media library and map it into the customization. |
| 1871 |
$customization = isset($generated['customization_data']) && is_array($generated['customization_data']) ? $generated['customization_data'] : []; |
| 1872 |
$logo = null; |
| 1873 |
$logo_url = !empty($generated['logo_url']) ? esc_url_raw($generated['logo_url']) : ''; |
| 1874 |
|
| 1875 |
if (!empty($logo_url)) { |
| 1876 |
$logo_started = microtime(true); |
| 1877 |
$uploaded = Utils::upload_logo($logo_url, $session_id); |
| 1878 |
Helper::log(sprintf('chatbot_import_prepare[%s] logo upload in %.2fs', $chat, microtime(true) - $logo_started), 'ai-import', 'info'); |
| 1879 |
if (!empty($uploaded['id'])) { |
| 1880 |
$logo = [ |
| 1881 |
'id' => (int) $uploaded['id'], |
| 1882 |
'url' => $uploaded['url'], |
| 1883 |
]; |
| 1884 |
} elseif (!empty($uploaded['error'])) { |
| 1885 |
// Logo is non-fatal: log and continue without it. |
| 1886 |
Helper::log('chatbot_import_prepare logo upload failed: ' . $uploaded['error']); |
| 1887 |
} |
| 1888 |
} |
| 1889 |
|
| 1890 |
// Reflect the imported logo back into the customization payload so React |
| 1891 |
// can build the settings FormData from a single source. |
| 1892 |
if (!empty($logo)) { |
| 1893 |
$customization['logo'] = $logo; |
| 1894 |
} |
| 1895 |
|
| 1896 |
Helper::log(sprintf('chatbot_import_prepare[%s] done in %.2fs total', $chat, microtime(true) - $handler_started), 'ai-import', 'info'); |
| 1897 |
|
| 1898 |
return [ |
| 1899 |
'status' => 'success', |
| 1900 |
'data' => [ |
| 1901 |
'session_id' => $session_id, |
| 1902 |
'process_id' => $process_id, |
| 1903 |
'ai_page_ids' => $ai_page_ids, |
| 1904 |
'saved' => $saved_count, |
| 1905 |
// Pages the backend explicitly skipped — imported with the |
| 1906 |
// pack's default content instead. |
| 1907 |
'skipped' => $skipped_expected, |
| 1908 |
// Readiness snapshot at import start. `missing` pages are NOT a |
| 1909 |
// failure: the Finalizer pulls each on demand and waits for it. |
| 1910 |
'expected' => $expected_ids, |
| 1911 |
'ready' => $ready, |
| 1912 |
'missing' => $missing, |
| 1913 |
'platform' => isset($customization['platform']) ? $customization['platform'] : null, |
| 1914 |
'customization_data' => $customization, |
| 1915 |
'logo' => $logo, |
| 1916 |
'errors' => $errors, |
| 1917 |
], |
| 1918 |
]; |
| 1919 |
} |
| 1920 |
|
| 1921 |
/** |
| 1922 |
* Validate API key against database |
| 1923 |
* Checks if the provided API key exists for any user on the current site |
| 1924 |
* Handles both single-site and multisite WordPress installations |
| 1925 |
* |
| 1926 |
* @param string $api_key The API key to validate |
| 1927 |
* @return bool True if valid, false otherwise |
| 1928 |
*/ |
| 1929 |
private function validate_api_key_in_db($api_key) { |
| 1930 |
global $wpdb; |
| 1931 |
|
| 1932 |
$api_key = sanitize_text_field($api_key); |
| 1933 |
|
| 1934 |
if (empty($api_key)) { |
| 1935 |
return false; |
| 1936 |
} |
| 1937 |
|
| 1938 |
$meta_key = '_templately_api_key'; |
| 1939 |
|
| 1940 |
// Handle multisite: key will have site prefix in multisite |
| 1941 |
if (is_multisite()) { |
| 1942 |
// get_user_option() uses the format: {$wpdb->base_prefix}{$blog_id}_{$meta_key} |
| 1943 |
// For current blog, we need to check with the current blog prefix |
| 1944 |
$blog_id = get_current_blog_id(); |
| 1945 |
$meta_key = $wpdb->get_blog_prefix($blog_id) . $meta_key; |
| 1946 |
} |
| 1947 |
|
| 1948 |
// Query to check if this API key exists for any user |
| 1949 |
$query = $wpdb->prepare( |
| 1950 |
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1", |
| 1951 |
$meta_key, |
| 1952 |
$api_key |
| 1953 |
); |
| 1954 |
|
| 1955 |
$user_id = $wpdb->get_var($query); |
| 1956 |
|
| 1957 |
return !empty($user_id); |
| 1958 |
} |
| 1959 |
} |
| 1960 |
|