PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.3
MxChat – AI Chatbot & Content Generation for WordPress v3.2.3
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-content-generator.php

class-mxchat-content-generator.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.3, at includes/class-mxchat-content-generator.php

3,242 lines 137.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MxChat Content Generator
4 *
5 * Handles AI-powered blog post and landing page generation
6 * with image generation, SEO metadata, and inline editing.
7 *
8 * @package MxChat
9 * @since 3.1.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class MxChat_Content_Generator {
17
18 private $options;
19
20 public function __construct() {
21 $this->options = get_option('mxchat_options', array());
22
23 // AJAX hooks (admin only, but wp_ajax_ prefix ensures that)
24 add_action('wp_ajax_mxchat_generate_content', array($this, 'handle_generate_content'));
25 add_action('wp_ajax_mxchat_content_edit', array($this, 'handle_content_edit'));
26 add_action('wp_ajax_mxchat_content_progress', array($this, 'handle_content_progress'));
27 add_action('wp_ajax_mxchat_save_content_setting', array($this, 'handle_save_content_setting'));
28 add_action('wp_ajax_mxchat_content_history', array($this, 'handle_content_history'));
29 add_action('wp_ajax_mxchat_load_post_for_edit', array($this, 'handle_load_post_for_edit'));
30 add_action('wp_ajax_mxchat_delete_content', array($this, 'handle_delete_content'));
31 add_action('wp_ajax_mxchat_update_post_status', array($this, 'handle_update_post_status'));
32 add_action('wp_ajax_mxchat_seo_analyze', array($this, 'handle_seo_analyze'));
33 add_action('wp_ajax_mxchat_seo_analyze_batch', array($this, 'handle_seo_analyze_batch'));
34 add_action('wp_ajax_mxchat_seo_suggest', array($this, 'handle_seo_suggest'));
35 add_action('wp_ajax_mxchat_seo_list_posts', array($this, 'handle_seo_list_posts'));
36 add_action('wp_ajax_mxchat_get_default_prompt', array($this, 'handle_get_default_prompt'));
37 add_action('wp_ajax_mxchat_save_custom_prompt', array($this, 'handle_save_custom_prompt'));
38
39 // Background generation via loopback (nopriv because loopback doesn't carry cookies — auth via secret token)
40 add_action('wp_ajax_nopriv_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
41 add_action('wp_ajax_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
42
43 // Frontend CSS injection — outputs generated styles in <head>
44 add_action('wp_head', array($this, 'inject_generated_css'));
45
46 // Hide admin bar inside content generator preview iframe (WordPress-native approach)
47 add_filter('show_admin_bar', array($this, 'hide_admin_bar_in_preview'));
48
49 // Disable wpautop and wptexturize for generated content.
50 // wpautop inserts rogue <p> tags between block elements (section, div, etc.)
51 // which breaks flex/grid layouts. wptexturize converts quotes inside CSS
52 // values and data attributes into curly quotes, breaking functionality.
53 add_filter('the_content', array($this, 'protect_generated_content'), 1);
54 add_filter('the_content', array($this, 'restore_content_filters'), 999);
55 }
56
57 // ─── Content Filter Protection ──────────────────────────────────
58
59 /**
60 * Disable wpautop and wptexturize for MxChat-generated posts.
61 *
62 * WordPress applies these filters to the_content by default:
63 * - wpautop: wraps text in <p> tags, breaking flex/grid layouts
64 * - wptexturize: converts quotes to curly quotes, breaking attributes
65 *
66 * This runs at priority 1 (earliest) to remove the filters before
67 * they execute, then restore_content_filters() at priority 999
68 * re-adds them for subsequent posts (e.g. in archive/loop contexts).
69 */
70 public function protect_generated_content($content) {
71 $post_id = get_the_ID();
72 if (!$post_id) {
73 return $content;
74 }
75
76 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
77 return $content;
78 }
79
80 // Remove wpautop and wptexturize for this post
81 remove_filter('the_content', 'wpautop');
82 remove_filter('the_content', 'wptexturize');
83
84 return $content;
85 }
86
87 /**
88 * Re-add wpautop and wptexturize after our generated content has rendered.
89 * This ensures other posts in the same page load (archives, widgets)
90 * still get normal WordPress formatting.
91 */
92 public function restore_content_filters($content) {
93 if (!has_filter('the_content', 'wpautop')) {
94 add_filter('the_content', 'wpautop');
95 }
96 if (!has_filter('the_content', 'wptexturize')) {
97 add_filter('the_content', 'wptexturize');
98 }
99
100 return $content;
101 }
102
103 // ─── Frontend CSS Injection ──────────────────────────────────────
104
105 /**
106 * Inject generated CSS into <head> on the frontend.
107 * Legacy fallback for posts created before CSS was embedded in post_content.
108 * New posts (with mxchat-css CSS comment marker) skip this entirely.
109 */
110 public function inject_generated_css() {
111 if (!is_singular()) {
112 return;
113 }
114
115 $post_id = get_the_ID();
116 if (!$post_id) {
117 return;
118 }
119
120 // Only inject on MxChat-generated posts
121 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
122 return;
123 }
124
125 // New-format posts have CSS embedded in post_content — skip injection
126 $post = get_post($post_id);
127 if ($post && strpos($post->post_content, '/* mxchat-css */') !== false) {
128 return;
129 }
130
131 // Legacy fallback: inject via wp_head as before
132 $ai_css = get_post_meta($post_id, '_mxchat_content_css', true);
133 $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1';
134 $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1';
135
136 echo $this->get_generated_css($fullwidth, $ai_css);
137
138 if ($hide_title) {
139 echo '<style>' . $this->get_title_hide_css() . '</style>' . "\n";
140 }
141 }
142
143 /**
144 * Hide the WordPress admin bar when the page is loaded inside the
145 * content generator preview iframe (?mxchat_preview=1).
146 *
147 * Uses the native show_admin_bar filter so WordPress never renders
148 * the bar at all — no CSS hacks, no flash of the bar disappearing.
149 *
150 * @param bool $show Whether to show the admin bar.
151 * @return bool
152 */
153 public function hide_admin_bar_in_preview($show) {
154 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, no data processing
155 if (isset($_GET['mxchat_preview'])) {
156 return false;
157 }
158 return $show;
159 }
160
161 // ─── HTML Sanitization ────────────────────────────────────────────
162
163 /**
164 * Sanitize AI-generated HTML allowing all safe CSS properties.
165 * WordPress wp_kses_post() strips most inline styles (display, flex,
166 * background, gradient, border-radius, box-shadow, etc.) which breaks
167 * modern page layouts. This method uses a permissive allowlist for
168 * admin-generated content only.
169 */
170 private function sanitize_generated_html($html) {
171 $allowed = wp_kses_allowed_html('post');
172
173 // Tags that need full style support
174 $styled_tags = array(
175 'div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'aside',
176 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'figure',
177 'figcaption', 'img', 'ul', 'ol', 'li', 'blockquote', 'table', 'thead',
178 'tbody', 'tr', 'th', 'td', 'button', 'strong', 'em', 'br', 'hr',
179 'video', 'source', 'iframe', 'svg', 'path', 'circle', 'rect', 'line',
180 'polyline', 'polygon', 'g', 'defs', 'use', 'symbol', 'text',
181 );
182
183 foreach ($styled_tags as $tag) {
184 if (!isset($allowed[$tag])) {
185 $allowed[$tag] = array();
186 }
187 $allowed[$tag]['style'] = true;
188 $allowed[$tag]['class'] = true;
189 $allowed[$tag]['id'] = true;
190 }
191
192 // iframe attributes for video embeds (YouTube, Vimeo, etc.)
193 $allowed['iframe']['src'] = true;
194 $allowed['iframe']['width'] = true;
195 $allowed['iframe']['height'] = true;
196 $allowed['iframe']['frameborder'] = true;
197 $allowed['iframe']['allow'] = true;
198 $allowed['iframe']['allowfullscreen'] = true;
199 $allowed['iframe']['title'] = true;
200 $allowed['iframe']['loading'] = true;
201
202 // Ensure a/img have their needed attributes
203 $allowed['a']['href'] = true;
204 $allowed['a']['target'] = true;
205 $allowed['a']['rel'] = true;
206 $allowed['img']['src'] = true;
207 $allowed['img']['alt'] = true;
208 $allowed['img']['width'] = true;
209 $allowed['img']['height'] = true;
210 $allowed['img']['loading'] = true;
211
212 // SVG attributes for inline icons
213 foreach (array('svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g') as $svg_tag) {
214 $allowed[$svg_tag]['xmlns'] = true;
215 $allowed[$svg_tag]['viewbox'] = true;
216 $allowed[$svg_tag]['fill'] = true;
217 $allowed[$svg_tag]['stroke'] = true;
218 $allowed[$svg_tag]['stroke-width'] = true;
219 $allowed[$svg_tag]['stroke-linecap'] = true;
220 $allowed[$svg_tag]['stroke-linejoin'] = true;
221 $allowed[$svg_tag]['d'] = true;
222 $allowed[$svg_tag]['cx'] = true;
223 $allowed[$svg_tag]['cy'] = true;
224 $allowed[$svg_tag]['r'] = true;
225 $allowed[$svg_tag]['x'] = true;
226 $allowed[$svg_tag]['y'] = true;
227 $allowed[$svg_tag]['width'] = true;
228 $allowed[$svg_tag]['height'] = true;
229 $allowed[$svg_tag]['points'] = true;
230 $allowed[$svg_tag]['x1'] = true;
231 $allowed[$svg_tag]['y1'] = true;
232 $allowed[$svg_tag]['x2'] = true;
233 $allowed[$svg_tag]['y2'] = true;
234 $allowed[$svg_tag]['transform'] = true;
235 }
236
237 // Allow all safe CSS properties via the safecss filter
238 add_filter('safe_style_css', array($this, 'allow_all_safe_css'));
239 $clean = wp_kses($html, $allowed);
240 remove_filter('safe_style_css', array($this, 'allow_all_safe_css'));
241
242 return $clean;
243 }
244
245 /**
246 * Expand the list of allowed CSS properties for generated content.
247 */
248 public function allow_all_safe_css($styles) {
249 $extra = array(
250 'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink',
251 'flex-basis', 'justify-content', 'align-items', 'align-self', 'gap', 'order',
252 'grid', 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row',
253 'grid-gap', 'grid-area',
254 'position', 'top', 'right', 'bottom', 'left', 'z-index',
255 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
256 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
257 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
258 'background', 'background-color', 'background-image', 'background-size',
259 'background-position', 'background-repeat', 'background-attachment',
260 'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
261 'border-radius', 'border-color', 'border-style', 'border-width',
262 'box-shadow', 'text-shadow',
263 'color', 'font-size', 'font-weight', 'font-style', 'font-family',
264 'line-height', 'letter-spacing', 'text-align', 'text-decoration', 'text-transform',
265 'vertical-align', 'white-space', 'word-break', 'overflow', 'overflow-x', 'overflow-y',
266 'opacity', 'visibility', 'cursor',
267 'transition', 'transform', 'animation',
268 'object-fit', 'object-position',
269 'list-style', 'list-style-type',
270 'aspect-ratio',
271 );
272 return array_unique(array_merge($styles, $extra));
273 }
274
275 // ─── Generation Pipeline ───────────────────────────────────────────
276
277 /**
278 * Main content generation handler
279 */
280 public function handle_generate_content() {
281 check_ajax_referer('mxchat_content_nonce', 'nonce');
282
283 if (!current_user_can('manage_options')) {
284 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
285 }
286
287 $prompt = sanitize_textarea_field($_POST['prompt'] ?? '');
288 $content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
289 $post_status = sanitize_text_field($_POST['post_status'] ?? 'draft');
290 $schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
291 $layout = sanitize_text_field($_POST['layout'] ?? 'fullwidth');
292 $title_display = sanitize_text_field($_POST['title_display'] ?? 'hide');
293 $template_mode = sanitize_text_field($_POST['template_mode'] ?? 'off');
294 $custom_system_prompt = wp_unslash($_POST['custom_system_prompt'] ?? '');
295
296 if (empty($prompt)) {
297 wp_send_json_error(array('message' => __('Please enter a prompt.', 'mxchat')));
298 }
299
300 // Validate inputs
301 if (!in_array($content_type, array('post', 'page'), true)) {
302 $content_type = 'post';
303 }
304 if (!in_array($post_status, array('draft', 'publish', 'future'), true)) {
305 $post_status = 'draft';
306 }
307
308 // Generate a unique progress key and secret for the background worker
309 $progress_key = 'mxchat_content_progress_' . get_current_user_id() . '_' . time();
310 $secret = wp_generate_password(32, false);
311
312 $this->update_progress($progress_key, 'starting', __('Starting generation...', 'mxchat'), 5);
313
314 // Store generation parameters for the background worker
315 $params = array(
316 'secret' => $secret,
317 'user_id' => get_current_user_id(),
318 'prompt' => $prompt,
319 'content_type' => $content_type,
320 'post_status' => $post_status,
321 'schedule_date' => $schedule_date,
322 'layout' => $layout,
323 'title_display' => $title_display,
324 'template_mode' => $template_mode,
325 'custom_system_prompt' => $custom_system_prompt,
326 );
327 set_transient($progress_key . '_params', $params, 600);
328
329 // Send JSON response to the browser immediately, then continue
330 // generation in the same PHP process. This avoids loopback requests
331 // which fail behind Cloudflare, CDNs, and on shared hosting.
332 //
333 // Strategy (works on all hosting environments):
334 // 1. litespeed_finish_request() — LiteSpeed servers (HostGator, etc.)
335 // 2. fastcgi_finish_request() — Nginx + PHP-FPM (most VPS/cloud hosts)
336 // 3. Connection: close + output buffer flush — Apache mod_php, CGI, any other SAPI
337 //
338 // All three approaches send the response to the client and allow PHP
339 // to continue executing in the background.
340
341 ignore_user_abort(true);
342 if (function_exists('set_time_limit')) {
343 set_time_limit(300);
344 }
345
346 $response_json = wp_json_encode(array('success' => true, 'data' => array('progress_key' => $progress_key)));
347
348 if (function_exists('litespeed_finish_request')) {
349 header('Content-Type: application/json; charset=utf-8');
350 echo $response_json;
351 litespeed_finish_request();
352 } elseif (function_exists('fastcgi_finish_request')) {
353 header('Content-Type: application/json; charset=utf-8');
354 echo $response_json;
355 fastcgi_finish_request();
356 } else {
357 // Universal fallback: close the connection via headers + output buffer flush.
358 // Works on Apache mod_php, CGI, and any SAPI that doesn't have a finish function.
359 header('Content-Type: application/json; charset=utf-8');
360 header('Connection: close');
361 header('Content-Encoding: none');
362
363 // Clear any existing output buffers
364 while (ob_get_level() > 0) {
365 ob_end_clean();
366 }
367
368 ob_start();
369 echo $response_json;
370 $size = ob_get_length();
371 header('Content-Length: ' . $size);
372 ob_end_flush();
373 flush();
374 }
375
376 // Client has received the response and is now polling for progress.
377 // Run generation inline in this same PHP process.
378 $this->run_background_generation($progress_key, $params);
379 die();
380 }
381
382 /**
383 * Background content generation handler.
384 * Called via non-blocking loopback from handle_generate_content().
385 * Authenticated via secret token stored in transient (no nonce/cookie needed).
386 */
387 public function handle_generate_content_background() {
388 $progress_key = sanitize_text_field($_POST['progress_key'] ?? '');
389 $secret = sanitize_text_field($_POST['secret'] ?? '');
390
391 if (empty($progress_key) || empty($secret)) {
392 die();
393 }
394
395 // Validate the secret token
396 $params = get_transient($progress_key . '_params');
397 if (!$params || !isset($params['secret']) || $params['secret'] !== $secret) {
398 die();
399 }
400
401 // One-time use — delete params transient
402 delete_transient($progress_key . '_params');
403
404 // Set up execution environment
405 if (function_exists('set_time_limit')) {
406 set_time_limit(300);
407 }
408 ignore_user_abort(true);
409
410 // Restore the original user context
411 wp_set_current_user($params['user_id']);
412
413 $this->run_background_generation($progress_key, $params);
414 die();
415 }
416
417 /**
418 * Core generation logic — used by both loopback and inline execution paths.
419 */
420 private function run_background_generation($progress_key, $params) {
421 $prompt = $params['prompt'];
422 $content_type = $params['content_type'];
423 $post_status = $params['post_status'];
424 $schedule_date = $params['schedule_date'];
425 $layout = $params['layout'];
426 $title_display = $params['title_display'];
427 $template_mode = $params['template_mode'] ?? 'off';
428 $custom_system_prompt = $params['custom_system_prompt'] ?? '';
429
430 $this->update_progress($progress_key, 'planning', __('Planning content structure...', 'mxchat'), 10);
431
432 // Step 1: Plan the content
433 $plan = $this->plan_content($prompt, $content_type);
434 if (is_wp_error($plan)) {
435 $this->update_progress($progress_key, 'error', $plan->get_error_message(), 0);
436 return;
437 }
438
439 $this->update_progress($progress_key, 'images', __('Generating images...', 'mxchat'), 30);
440
441 // Step 2: Generate images
442 $image_urls = $this->generate_content_images($plan, $progress_key);
443
444 $this->update_progress($progress_key, 'writing', __('Writing full content...', 'mxchat'), 60);
445
446 // Step 3: Generate full HTML content
447 $html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt, $custom_system_prompt);
448 if (is_wp_error($html_content)) {
449 $this->update_progress($progress_key, 'error', $html_content->get_error_message(), 0);
450 return;
451 }
452
453 $this->update_progress($progress_key, 'creating', __('Creating WordPress post...', 'mxchat'), 85);
454
455 // Step 4: Create the WordPress post/page
456 // Extract AI CSS — saved to post meta for edit workflow, and embedded in post_content for portability
457 $ai_css = $this->extract_css($html_content);
458 $html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $html_content);
459 $sanitized_html = $this->sanitize_generated_html($html_without_style);
460
461 // Build self-contained <style> block (all layers) and prepend to HTML
462 $is_fullwidth = ($layout === 'fullwidth');
463 $is_hide_title = ($title_display === 'hide');
464 $embedded_css = $this->build_embedded_css($ai_css, $is_fullwidth, $is_hide_title);
465 $full_content = $embedded_css . $sanitized_html;
466
467 $post_args = array(
468 'post_title' => sanitize_text_field($plan['title']),
469 'post_content' => $full_content,
470 'post_status' => $post_status,
471 'post_type' => $content_type === 'page' ? 'page' : 'post',
472 'meta_input' => array(
473 '_mxchat_generated' => '1',
474 '_mxchat_prompt' => $prompt,
475 '_mxchat_fullwidth' => ($layout === 'fullwidth') ? '1' : '0',
476 '_mxchat_hide_title' => ($title_display === 'hide') ? '1' : '0',
477 '_mxchat_content_css' => $ai_css,
478 '_mxchat_template_mode' => ($template_mode === 'on') ? '1' : '0',
479 ),
480 );
481
482 // Handle scheduled posts
483 if ($post_status === 'future' && !empty($schedule_date)) {
484 $post_args['post_date'] = $schedule_date;
485 $post_args['post_date_gmt'] = get_gmt_from_date($schedule_date);
486 }
487
488 $post_id = wp_insert_post($post_args, true);
489
490 if (is_wp_error($post_id)) {
491 $this->update_progress($progress_key, 'error', $post_id->get_error_message(), 0);
492 return;
493 }
494
495 // Set featured image if we have one
496 if (!empty($image_urls) && !empty($image_urls[0]['attachment_id'])) {
497 set_post_thumbnail($post_id, $image_urls[0]['attachment_id']);
498 }
499
500 // Associate all generated images with this post and store IDs for reliable tracking
501 $image_ids = array();
502 foreach ($image_urls as $img) {
503 if (!empty($img['attachment_id'])) {
504 wp_update_post(array(
505 'ID' => $img['attachment_id'],
506 'post_parent' => $post_id,
507 ));
508 $image_ids[] = $img['attachment_id'];
509 }
510 }
511 if (!empty($image_ids)) {
512 update_post_meta($post_id, '_mxchat_image_ids', $image_ids);
513 }
514
515 // Apply fullwidth/title settings via theme-specific post meta
516 $this->apply_layout_settings($post_id, $layout, $title_display);
517
518 // Step 5: Fill SEO metadata
519 $this->fill_seo_metadata($post_id, $plan);
520
521 // Build final result
522 $preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
523 $edit_url = admin_url('post.php?post=' . $post_id . '&action=edit');
524 $permalink = get_permalink($post_id);
525
526 $images_for_response = array();
527 foreach ($image_urls as $img) {
528 if (!empty($img['url']) && !empty($img['attachment_id'])) {
529 $thumb_url = wp_get_attachment_image_url($img['attachment_id'], 'medium');
530 $images_for_response[] = array(
531 'url' => $img['url'],
532 'thumbnail' => $thumb_url ?: $img['url'],
533 'attachment_id' => $img['attachment_id'],
534 );
535 }
536 }
537
538 $result = array(
539 'post_id' => $post_id,
540 'preview_url' => $preview_url,
541 'edit_url' => $edit_url,
542 'permalink' => $permalink,
543 'title' => $plan['title'],
544 'status' => $post_status,
545 'images' => $images_for_response,
546 'meta' => array(
547 'description' => $plan['meta_description'] ?? '',
548 'keyword' => !empty($plan['keywords']) ? $plan['keywords'][0] : '',
549 'excerpt' => '',
550 ),
551 );
552
553 // Store the result in the progress transient so polling can retrieve it
554 $this->update_progress($progress_key, 'done', __('Content generated successfully!', 'mxchat'), 100, $result);
555 }
556
557 /**
558 * Handle content edit via mini-chat
559 */
560 public function handle_content_edit() {
561 check_ajax_referer('mxchat_content_nonce', 'nonce');
562
563 if (!current_user_can('manage_options')) {
564 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
565 }
566
567 // Extend PHP execution time for long AI calls
568 if (function_exists('set_time_limit')) {
569 set_time_limit(300);
570 }
571
572 $post_id = intval($_POST['post_id'] ?? 0);
573 $edit_instruction = sanitize_textarea_field($_POST['edit_instruction'] ?? '');
574
575 if (!$post_id || empty($edit_instruction)) {
576 wp_send_json_error(array('message' => __('Missing post ID or edit instruction.', 'mxchat')));
577 }
578
579 $post = get_post($post_id);
580 if (!$post) {
581 wp_send_json_error(array('message' => __('Post not found.', 'mxchat')));
582 }
583
584 $current_content = $post->post_content;
585 $current_title = $post->post_title;
586 $current_css = get_post_meta($post_id, '_mxchat_content_css', true);
587
588 // Build the edit prompt — send both CSS and HTML so AI can edit either
589 $system_prompt = "You are a content editor. You have an existing page that uses a CSS-first approach: a <style> block with mxg- prefixed classes followed by clean HTML.\n\nApply ONLY the requested change and return the complete updated output. Do not add commentary — return ONLY the updated <style> block + HTML.\n\nIMPORTANT RULES:\n- Keep the same overall structure\n- Only change what the user specifically asks for\n- Return the complete output (not just the changed part)\n- If the user asks to change the title, update the <h1> in the HTML\n- Maintain the <style> block — update CSS rules if the edit requires style changes\n- ALL class names must use the mxg- prefix\n- Do NOT add inline styles — all styling stays in the <style> block\n- Do NOT include HTML comments\n- Do NOT generate a <header>, <footer>, or <nav>";
590
591 // Strip embedded boilerplate CSS from post_content (Layer 1/2 + marker)
592 // so the AI only sees the clean AI CSS + HTML
593 $clean_content = preg_replace('/<style>\/\* mxchat-css \*\/.*?<\/style>\s*/is', '', $current_content);
594
595 // Reconstruct: prepend only the AI CSS (from meta) + clean HTML
596 $full_content_for_ai = '';
597 if (!empty($current_css)) {
598 $full_content_for_ai = "<style>\n" . $current_css . "\n</style>\n";
599 }
600 $full_content_for_ai .= $clean_content;
601
602 $user_message = "Current post title: " . $current_title . "\n\nCurrent content (style block + HTML):\n" . $full_content_for_ai . "\n\nUser edit request: " . $edit_instruction;
603
604 $messages = array(
605 array('role' => 'user', 'content' => $user_message),
606 );
607
608 // Allow add-ons to handle the edit via tool calling (str_replace, etc.)
609 // Filter returns null to fall through to the default full-rewrite approach.
610 $result = apply_filters('mxchat_content_tool_edit', null, $full_content_for_ai, $edit_instruction, $current_title);
611
612 if ($result === null) {
613 // Default: AI rewrites entire page
614 $result = $this->call_content_model($system_prompt, $messages, 16384);
615 }
616
617 if (is_wp_error($result)) {
618 wp_send_json_error(array('message' => $result->get_error_message()));
619 }
620
621 // Clean up code fences if present
622 $result = trim($result);
623 $result = preg_replace('/^```(?:html)?\s*/i', '', $result);
624 $result = preg_replace('/\s*```\s*$/', '', $result);
625
626 // Strip HTML comments
627 $result = preg_replace('/<!--.*?-->/s', '', $result);
628
629 // Extract title if it was changed (look for first h1)
630 $new_title = $current_title;
631 if (preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $result, $title_match)) {
632 $new_title = wp_strip_all_tags($title_match[1]);
633 }
634
635 // Extract CSS → meta (for edit workflow), embed full CSS in post_content (for portability)
636 $ai_css = $this->extract_css($result);
637 $html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $result);
638 $sanitized_html = $this->sanitize_generated_html($html_without_style);
639
640 // Re-embed all CSS layers into post_content
641 $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1';
642 $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1';
643 $embedded_css = $this->build_embedded_css($ai_css, $fullwidth, $hide_title);
644 $full_content = $embedded_css . $sanitized_html;
645
646 // Clear invalid page template meta that causes "Invalid page template" errors
647 $current_template = get_post_meta($post_id, '_wp_page_template', true);
648 if (!empty($current_template) && $current_template !== 'default') {
649 $theme_templates = wp_get_theme()->get_page_templates(get_post($post_id));
650 if (!isset($theme_templates[$current_template])) {
651 delete_post_meta($post_id, '_wp_page_template');
652 }
653 }
654
655 // Update the post content (CSS + HTML) and meta
656 $update_result = wp_update_post(array(
657 'ID' => $post_id,
658 'post_title' => sanitize_text_field($new_title),
659 'post_content' => $full_content,
660 ), true);
661
662 // Keep CSS in meta for edit workflow reconstruction
663 update_post_meta($post_id, '_mxchat_content_css', $ai_css);
664
665 if (is_wp_error($update_result)) {
666 wp_send_json_error(array('message' => $update_result->get_error_message()));
667 }
668
669 $preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
670
671 // Discover images — prefer stored IDs, fall back to post_parent query
672 $images = $this->discover_post_images($post_id);
673
674 wp_send_json_success(array(
675 'post_id' => $post_id,
676 'preview_url' => $preview_url,
677 'title' => $new_title,
678 'message' => __('Content updated successfully.', 'mxchat'),
679 'images' => $images,
680 'meta' => array(
681 'description' => $this->get_meta_description($post_id),
682 'keyword' => $this->get_focus_keyword($post_id),
683 'excerpt' => get_post($post_id)->post_excerpt,
684 ),
685 ));
686 }
687
688 /**
689 * Return current progress status for polling
690 */
691 public function handle_content_progress() {
692 // Prevent proxy/CDN from caching progress responses
693 nocache_headers();
694
695 check_ajax_referer('mxchat_content_nonce', 'nonce');
696
697 $progress_key = sanitize_text_field($_POST['progress_key'] ?? '');
698 if (empty($progress_key)) {
699 wp_send_json_error(array('message' => __('Invalid progress key.', 'mxchat')));
700 }
701
702 // Direct DB read — bypasses all caching layers for guaranteed freshness
703 $progress = $this->get_progress($progress_key);
704 if (!$progress) {
705 wp_send_json_success(array(
706 'step' => 'waiting',
707 'message' => __('Waiting...', 'mxchat'),
708 'percent' => 0,
709 ));
710 return;
711 }
712
713 // Clean up completed/errored progress rows after reading
714 if (in_array($progress['step'], array('done', 'error'), true)) {
715 $this->delete_progress($progress_key);
716 }
717
718 wp_send_json_success($progress);
719 }
720
721 // ─── Content Planning ──────────────────────────────────────────────
722
723 /**
724 * Step 1: Ask AI to plan the content structure
725 */
726 private function plan_content($prompt, $content_type) {
727 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
728
729 $system_prompt = "You are a professional content strategist. The user wants to create a {$type_label}. Analyze their request and create a detailed content plan.\n\nYou MUST respond with ONLY valid JSON (no markdown, no code fences, no commentary). Use this exact structure:\n\n{\"title\": \"SEO-optimized title\", \"slug\": \"url-friendly-slug\", \"meta_description\": \"155 character meta description for SEO\", \"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"], \"links\": [{\"label\": \"Button or link text\", \"url\": \"https://example.com/page\"}], \"sections\": [{\"type\": \"hero\", \"heading\": \"Main heading\", \"subheading\": \"Supporting text\", \"needs_image\": true, \"image_prompt\": \"Detailed prompt for hero image\"}, {\"type\": \"content\", \"heading\": \"Section heading\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": true, \"image_prompt\": \"Detailed prompt for section image\"}, {\"type\": \"content\", \"heading\": \"Another section\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": false, \"image_prompt\": \"\"}, {\"type\": \"cta\", \"heading\": \"Call to action heading\", \"subheading\": \"CTA supporting text\", \"needs_image\": false, \"image_prompt\": \"\"}]}\n\nGuidelines:\n- Create 5-9 sections for blog posts, 7-11 for landing pages — add more if the user's request warrants extensive coverage\n- For landing pages, draw from sections like: hero, problem/solution, features, benefits, how-it-works, use cases, testimonials/social proof, pricing or comparison, FAQ, and final CTA — pick whichever fit the request\n- Each content section should include 3-5 substantive key_points (full descriptive sentences, not one-word labels) so the HTML generator has enough material to write meaningful copy\n- Include 2-4 sections that need images\n- Image prompts should be detailed, descriptive, and suitable for AI image generation\n- Image prompts should describe photorealistic or illustrative images relevant to the content\n- Keywords should be relevant long-tail SEO keywords\n- Title should be compelling and SEO-friendly\n- IMPORTANT: If the user specifies any URLs or links (for buttons, CTAs, navigation, etc.), you MUST capture every one of them in the \"links\" array with its label and exact URL. If no URLs are mentioned, use an empty array [].";
730
731 $messages = array(
732 array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"),
733 );
734
735 $result = $this->call_content_model($system_prompt, $messages);
736
737 if (is_wp_error($result)) {
738 return $result;
739 }
740
741 // Clean the response - remove markdown code fences if present
742 $result = trim($result);
743 $result = preg_replace('/^```(?:json)?\s*/i', '', $result);
744 $result = preg_replace('/\s*```\s*$/', '', $result);
745
746 $plan = json_decode($result, true);
747
748 if (json_last_error() !== JSON_ERROR_NONE) {
749 return new WP_Error('json_parse_error', __('Failed to parse content plan. The AI response was not valid JSON.', 'mxchat'));
750 }
751
752 // Validate required fields
753 if (empty($plan['title']) || empty($plan['sections'])) {
754 return new WP_Error('invalid_plan', __('Content plan is missing required fields (title or sections).', 'mxchat'));
755 }
756
757 return $plan;
758 }
759
760 // ─── Image Generation ──────────────────────────────────────────────
761
762 /**
763 * Step 2: Generate images for sections that need them.
764 * Uses cURL multi-handle to fire all image API requests in parallel,
765 * then saves results to the media library sequentially.
766 */
767 private function generate_content_images($plan, $progress_key) {
768 $options = get_option('mxchat_options', array());
769 $enable_images = ($options['content_enable_images'] ?? 'on') === 'on';
770
771 if (!$enable_images) {
772 return array();
773 }
774
775 $image_sections = array();
776 foreach ($plan['sections'] as $index => $section) {
777 if (!empty($section['needs_image']) && !empty($section['image_prompt'])) {
778 $image_sections[] = array(
779 'index' => $index,
780 'prompt' => $section['image_prompt'],
781 'heading' => $section['heading'] ?? 'Section',
782 );
783 }
784 }
785
786 if (empty($image_sections)) {
787 return array();
788 }
789
790 $total = count($image_sections);
791
792 // Fallback to sequential if cURL multi is unavailable
793 if (!function_exists('curl_multi_init')) {
794 return $this->generate_content_images_sequential($image_sections, $total, $progress_key);
795 }
796
797 $this->update_progress(
798 $progress_key,
799 'images',
800 sprintf(__('Generating %d images...', 'mxchat'), $total),
801 30
802 );
803
804 // Build cURL requests for all images
805 $image_model = $options['content_image_model'] ?? 'gpt-image-1.5';
806 $curl_configs = array();
807
808 foreach ($image_sections as $img) {
809 $config = $this->build_image_request($img['prompt'], $image_model, $options);
810 if (!is_wp_error($config)) {
811 $curl_configs[] = array(
812 'section_index' => $img['index'],
813 'prompt' => $img['prompt'],
814 'config' => $config,
815 );
816 }
817 }
818
819 if (empty($curl_configs)) {
820 return array();
821 }
822
823 // Fire all requests in parallel
824 $multi = curl_multi_init();
825 $handles = array();
826
827 foreach ($curl_configs as $i => $item) {
828 $ch = curl_init();
829 curl_setopt($ch, CURLOPT_URL, $item['config']['url']);
830 curl_setopt($ch, CURLOPT_POST, true);
831 curl_setopt($ch, CURLOPT_POSTFIELDS, $item['config']['body']);
832 curl_setopt($ch, CURLOPT_HTTPHEADER, $item['config']['headers']);
833 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
834 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
835 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
836
837 curl_multi_add_handle($multi, $ch);
838 $handles[$i] = array(
839 'handle' => $ch,
840 'section_index' => $item['section_index'],
841 'prompt' => $item['prompt'],
842 );
843 }
844
845 // Execute all requests concurrently
846 $running = null;
847 do {
848 $status = curl_multi_exec($multi, $running);
849 if ($running > 0) {
850 curl_multi_select($multi, 1.0);
851 }
852 } while ($running > 0 && $status === CURLM_OK);
853
854 // Collect responses
855 $raw_responses = array();
856 foreach ($handles as $i => $h) {
857 $body = curl_multi_getcontent($h['handle']);
858 $http_code = curl_getinfo($h['handle'], CURLINFO_HTTP_CODE);
859
860 curl_multi_remove_handle($multi, $h['handle']);
861 curl_close($h['handle']);
862
863 if ($http_code === 200 && !empty($body)) {
864 $raw_responses[] = array(
865 'section_index' => $h['section_index'],
866 'prompt' => $h['prompt'],
867 'body' => $body,
868 );
869 }
870 }
871 curl_multi_close($multi);
872
873 $this->update_progress(
874 $progress_key,
875 'images',
876 __('Saving images to media library...', 'mxchat'),
877 50
878 );
879
880 // Process responses and save to media library (sequential — fast)
881 $image_urls = array();
882 foreach ($raw_responses as $resp) {
883 $result = $this->process_image_response($resp['body'], $image_model, $options);
884 if (!is_wp_error($result)) {
885 // Store the original prompt for later regeneration by add-ons
886 update_post_meta($result['attachment_id'], '_mxchat_image_prompt', $resp['prompt']);
887 $image_urls[] = array(
888 'section_index' => $resp['section_index'],
889 'url' => $result['url'],
890 'attachment_id' => $result['attachment_id'],
891 );
892 }
893 }
894
895 return $image_urls;
896 }
897
898 /**
899 * Sequential fallback when cURL multi is not available.
900 */
901 private function generate_content_images_sequential($image_sections, $total, $progress_key) {
902 $image_urls = array();
903
904 foreach ($image_sections as $i => $img) {
905 $step_num = $i + 1;
906 $this->update_progress(
907 $progress_key,
908 'images',
909 sprintf(__('Generating image %d of %d...', 'mxchat'), $step_num, $total),
910 30 + (int)(($step_num / $total) * 25)
911 );
912
913 $image_result = $this->generate_single_image($img['prompt']);
914
915 if (is_wp_error($image_result)) {
916 continue;
917 }
918
919 // Store the original prompt for later regeneration by add-ons
920 update_post_meta($image_result['attachment_id'], '_mxchat_image_prompt', $img['prompt']);
921
922 $image_urls[] = array(
923 'section_index' => $img['index'],
924 'url' => $image_result['url'],
925 'attachment_id' => $image_result['attachment_id'],
926 );
927 }
928
929 return $image_urls;
930 }
931
932 /**
933 * Generate a single image using the configured image model
934 */
935 private function generate_single_image($prompt) {
936 $options = get_option('mxchat_options', array());
937 $image_model = $options['content_image_model'] ?? 'gpt-image-1.5';
938
939 if (strpos($image_model, 'gpt-image') === 0) {
940 return $this->generate_openai_image($prompt, $options);
941 } elseif (strpos($image_model, 'grok') === 0) {
942 return $this->generate_xai_image($prompt, $options);
943 } elseif (strpos($image_model, 'gemini') === 0) {
944 return $this->generate_gemini_image($prompt, $image_model, $options);
945 }
946
947 return new WP_Error('unknown_model', __('Unknown image model configured.', 'mxchat'));
948 }
949
950 /**
951 * Build a cURL-ready request config for a single image generation.
952 * Used by the parallel image pipeline.
953 *
954 * @param string $prompt Image generation prompt.
955 * @param string $image_model The configured image model ID.
956 * @param array $options Plugin options (contains API keys).
957 * @return array|WP_Error Array with 'url', 'headers', 'body' keys, or WP_Error.
958 */
959 private function build_image_request($prompt, $image_model, $options) {
960 if (strpos($image_model, 'gpt-image') === 0) {
961 $api_key = $options['api_key'] ?? '';
962 if (empty($api_key)) {
963 return new WP_Error('no_api_key', __('OpenAI API key not configured.', 'mxchat'));
964 }
965 return array(
966 'url' => 'https://api.openai.com/v1/images/generations',
967 'headers' => array(
968 'Authorization: Bearer ' . $api_key,
969 'Content-Type: application/json',
970 ),
971 'body' => wp_json_encode(array(
972 'model' => 'gpt-image-1.5',
973 'prompt' => $prompt,
974 'n' => 1,
975 'size' => '1536x1024',
976 'quality' => 'medium',
977 'output_format' => 'png',
978 )),
979 );
980 }
981
982 if (strpos($image_model, 'grok') === 0) {
983 $api_key = $options['xai_api_key'] ?? '';
984 if (empty($api_key)) {
985 return new WP_Error('no_api_key', __('xAI API key not configured.', 'mxchat'));
986 }
987 return array(
988 'url' => 'https://api.x.ai/v1/images/generations',
989 'headers' => array(
990 'Authorization: Bearer ' . $api_key,
991 'Content-Type: application/json',
992 ),
993 'body' => wp_json_encode(array(
994 'model' => $image_model,
995 'prompt' => $prompt,
996 'n' => 1,
997 'response_format' => 'url',
998 )),
999 );
1000 }
1001
1002 if (strpos($image_model, 'gemini') === 0) {
1003 $api_key = $options['gemini_api_key'] ?? '';
1004 if (empty($api_key)) {
1005 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
1006 }
1007 $api_version = (strpos($image_model, 'preview') !== false) ? 'v1beta' : 'v1beta';
1008 return array(
1009 'url' => "https://generativelanguage.googleapis.com/{$api_version}/models/{$image_model}:generateContent?key={$api_key}",
1010 'headers' => array(
1011 'Content-Type: application/json',
1012 ),
1013 'body' => wp_json_encode(array(
1014 'contents' => array(
1015 array(
1016 'parts' => array(
1017 array('text' => $prompt),
1018 ),
1019 ),
1020 ),
1021 'generationConfig' => array(
1022 'responseModalities' => array('TEXT', 'IMAGE'),
1023 'imageConfig' => array('aspectRatio' => '16:9'),
1024 ),
1025 )),
1026 );
1027 }
1028
1029 return new WP_Error('unknown_model', __('Unknown image model.', 'mxchat'));
1030 }
1031
1032 /**
1033 * Process a raw API response body from an image generation call.
1034 * Decodes the response, extracts image data, and saves to media library.
1035 *
1036 * @param string $response_body Raw JSON response body.
1037 * @param string $image_model The image model used (determines response format).
1038 * @param array $options Plugin options.
1039 * @return array|WP_Error Array with 'url' and 'attachment_id', or WP_Error.
1040 */
1041 private function process_image_response($response_body, $image_model, $options = array()) {
1042 $decoded = json_decode($response_body, true);
1043 if (json_last_error() !== JSON_ERROR_NONE || empty($decoded)) {
1044 return new WP_Error('json_error', __('Invalid image API response.', 'mxchat'));
1045 }
1046
1047 // OpenAI GPT Image — b64_json or url
1048 if (strpos($image_model, 'gpt-image') === 0) {
1049 $b64_data = $decoded['data'][0]['b64_json'] ?? '';
1050 if (!empty($b64_data)) {
1051 return $this->save_image_to_media_library($b64_data, 'image/png');
1052 }
1053 $url = $decoded['data'][0]['url'] ?? '';
1054 if (!empty($url)) {
1055 return $this->save_image_url_to_media_library($url);
1056 }
1057 return new WP_Error('no_image_data', __('No image data in OpenAI response.', 'mxchat'));
1058 }
1059
1060 // xAI Grok — url
1061 if (strpos($image_model, 'grok') === 0) {
1062 $url = $decoded['data'][0]['url'] ?? '';
1063 if (!empty($url)) {
1064 return $this->save_image_url_to_media_library($url);
1065 }
1066 return new WP_Error('no_image_data', __('No image data in xAI response.', 'mxchat'));
1067 }
1068
1069 // Gemini — inlineData base64
1070 if (strpos($image_model, 'gemini') === 0) {
1071 if (isset($decoded['candidates'][0]['content']['parts'])) {
1072 foreach ($decoded['candidates'][0]['content']['parts'] as $part) {
1073 $inline_data = $part['inlineData'] ?? $part['inline_data'] ?? null;
1074 if ($inline_data && !empty($inline_data['data'])) {
1075 $mime_type = $inline_data['mimeType'] ?? $inline_data['mime_type'] ?? 'image/png';
1076 return $this->save_image_to_media_library($inline_data['data'], $mime_type);
1077 }
1078 }
1079 }
1080 return new WP_Error('no_image_data', __('No image data in Gemini response.', 'mxchat'));
1081 }
1082
1083 return new WP_Error('unknown_model', __('Unknown image model.', 'mxchat'));
1084 }
1085
1086 /**
1087 * Generate image via OpenAI GPT Image 1.5
1088 */
1089 private function generate_openai_image($prompt, $options) {
1090 $api_key = $options['api_key'] ?? '';
1091 if (empty($api_key)) {
1092 return new WP_Error('no_api_key', __('OpenAI API key not configured.', 'mxchat'));
1093 }
1094
1095 $body = array(
1096 'model' => 'gpt-image-1.5',
1097 'prompt' => $prompt,
1098 'n' => 1,
1099 'size' => '1536x1024',
1100 'quality' => 'medium',
1101 'output_format' => 'png',
1102 );
1103
1104 $response = wp_remote_post('https://api.openai.com/v1/images/generations', array(
1105 'headers' => array(
1106 'Authorization' => 'Bearer ' . $api_key,
1107 'Content-Type' => 'application/json',
1108 ),
1109 'body' => wp_json_encode($body),
1110 'timeout' => 120,
1111 ));
1112
1113 if (is_wp_error($response)) {
1114 return $response;
1115 }
1116
1117 $status_code = wp_remote_retrieve_response_code($response);
1118 $response_body = json_decode(wp_remote_retrieve_body($response), true);
1119
1120 if ($status_code !== 200) {
1121 $error_msg = $response_body['error']['message'] ?? __('OpenAI image generation failed.', 'mxchat');
1122 return new WP_Error('openai_image_error', $error_msg);
1123 }
1124
1125 // GPT Image returns b64_json by default
1126 $b64_data = $response_body['data'][0]['b64_json'] ?? '';
1127 if (!empty($b64_data)) {
1128 return $this->save_image_to_media_library($b64_data, 'image/png');
1129 }
1130
1131 // Fallback: URL-based response
1132 $image_url = $response_body['data'][0]['url'] ?? '';
1133 if (!empty($image_url)) {
1134 return $this->save_image_url_to_media_library($image_url);
1135 }
1136
1137 return new WP_Error('no_image_data', __('No image data in OpenAI response.', 'mxchat'));
1138 }
1139
1140 /**
1141 * Generate image via xAI Grok Imagine
1142 */
1143 private function generate_xai_image($prompt, $options) {
1144 $api_key = $options['xai_api_key'] ?? '';
1145 if (empty($api_key)) {
1146 return new WP_Error('no_api_key', __('xAI API key not configured.', 'mxchat'));
1147 }
1148
1149 $image_model = $options['content_image_model'] ?? 'grok-imagine-image';
1150
1151 $body = array(
1152 'model' => $image_model,
1153 'prompt' => $prompt,
1154 'n' => 1,
1155 'response_format' => 'url',
1156 );
1157
1158 $response = wp_remote_post('https://api.x.ai/v1/images/generations', array(
1159 'headers' => array(
1160 'Authorization' => 'Bearer ' . $api_key,
1161 'Content-Type' => 'application/json',
1162 ),
1163 'body' => wp_json_encode($body),
1164 'timeout' => 120,
1165 ));
1166
1167 if (is_wp_error($response)) {
1168 return $response;
1169 }
1170
1171 $status_code = wp_remote_retrieve_response_code($response);
1172 $response_body = json_decode(wp_remote_retrieve_body($response), true);
1173
1174 if ($status_code !== 200) {
1175 $error_msg = $response_body['error']['message'] ?? __('xAI image generation failed.', 'mxchat');
1176 return new WP_Error('xai_image_error', $error_msg);
1177 }
1178
1179 $image_url = $response_body['data'][0]['url'] ?? '';
1180 if (!empty($image_url)) {
1181 return $this->save_image_url_to_media_library($image_url);
1182 }
1183
1184 return new WP_Error('no_image_data', __('No image data in xAI response.', 'mxchat'));
1185 }
1186
1187 /**
1188 * Generate image via Gemini (Nano Banana)
1189 */
1190 private function generate_gemini_image($prompt, $model, $options) {
1191 $api_key = $options['gemini_api_key'] ?? '';
1192 if (empty($api_key)) {
1193 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
1194 }
1195
1196 $body = array(
1197 'contents' => array(
1198 array(
1199 'parts' => array(
1200 array('text' => $prompt),
1201 ),
1202 ),
1203 ),
1204 'generationConfig' => array(
1205 'responseModalities' => array('TEXT', 'IMAGE'),
1206 'imageConfig' => array(
1207 'aspectRatio' => '16:9',
1208 ),
1209 ),
1210 );
1211
1212 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
1213
1214 $response = wp_remote_post($url, array(
1215 'headers' => array(
1216 'Content-Type' => 'application/json',
1217 ),
1218 'body' => wp_json_encode($body),
1219 'timeout' => 120,
1220 ));
1221
1222 if (is_wp_error($response)) {
1223 return $response;
1224 }
1225
1226 $status_code = wp_remote_retrieve_response_code($response);
1227 $response_body = json_decode(wp_remote_retrieve_body($response), true);
1228
1229 if ($status_code !== 200) {
1230 $error_msg = $response_body['error']['message'] ?? __('Gemini image generation failed.', 'mxchat');
1231 return new WP_Error('gemini_image_error', $error_msg);
1232 }
1233
1234 // Extract image from Gemini response
1235 if (isset($response_body['candidates'][0]['content']['parts'])) {
1236 foreach ($response_body['candidates'][0]['content']['parts'] as $part) {
1237 $inline_data = $part['inlineData'] ?? $part['inline_data'] ?? null;
1238 if ($inline_data && !empty($inline_data['data'])) {
1239 $mime_type = $inline_data['mimeType'] ?? $inline_data['mime_type'] ?? 'image/png';
1240 return $this->save_image_to_media_library($inline_data['data'], $mime_type);
1241 }
1242 }
1243 }
1244
1245 return new WP_Error('no_image_data', __('No image data in Gemini response.', 'mxchat'));
1246 }
1247
1248 // ─── Media Library Helpers ──────────────────────────────────────────
1249
1250 /**
1251 * Save base64 image data to WordPress media library
1252 */
1253 private function save_image_to_media_library($base64_data, $mime_type = 'image/png') {
1254 require_once(ABSPATH . 'wp-admin/includes/media.php');
1255 require_once(ABSPATH . 'wp-admin/includes/file.php');
1256 require_once(ABSPATH . 'wp-admin/includes/image.php');
1257
1258 $image_content = base64_decode($base64_data);
1259 if ($image_content === false) {
1260 return new WP_Error('decode_failed', __('Failed to decode image data.', 'mxchat'));
1261 }
1262
1263 $extension = 'png';
1264 if (strpos($mime_type, 'jpeg') !== false || strpos($mime_type, 'jpg') !== false) {
1265 $extension = 'jpg';
1266 } elseif (strpos($mime_type, 'webp') !== false) {
1267 $extension = 'webp';
1268 }
1269
1270 $filename = 'mxchat-content-' . time() . '-' . wp_generate_password(6, false) . '.' . $extension;
1271 $temp_file = wp_tempnam($filename);
1272
1273 if (!$temp_file) {
1274 return new WP_Error('temp_file_failed', __('Could not create temporary file.', 'mxchat'));
1275 }
1276
1277 $bytes = file_put_contents($temp_file, $image_content);
1278 if ($bytes === false) {
1279 @unlink($temp_file);
1280 return new WP_Error('write_failed', __('Could not write image file.', 'mxchat'));
1281 }
1282
1283 $file_array = array(
1284 'name' => $filename,
1285 'tmp_name' => $temp_file,
1286 'type' => $mime_type,
1287 );
1288
1289 $attachment_id = media_handle_sideload($file_array, 0);
1290
1291 if (is_wp_error($attachment_id)) {
1292 @unlink($temp_file);
1293 return $attachment_id;
1294 }
1295
1296 return array(
1297 'url' => wp_get_attachment_url($attachment_id),
1298 'attachment_id' => $attachment_id,
1299 );
1300 }
1301
1302 /**
1303 * Download an image from URL and save to WordPress media library
1304 */
1305 private function save_image_url_to_media_library($image_url) {
1306 require_once(ABSPATH . 'wp-admin/includes/media.php');
1307 require_once(ABSPATH . 'wp-admin/includes/file.php');
1308 require_once(ABSPATH . 'wp-admin/includes/image.php');
1309
1310 $tmp = download_url($image_url, 120);
1311 if (is_wp_error($tmp)) {
1312 return $tmp;
1313 }
1314
1315 $filename = 'mxchat-content-' . time() . '-' . wp_generate_password(6, false) . '.jpg';
1316
1317 $file_array = array(
1318 'name' => $filename,
1319 'tmp_name' => $tmp,
1320 );
1321
1322 $attachment_id = media_handle_sideload($file_array, 0);
1323
1324 if (is_wp_error($attachment_id)) {
1325 @unlink($tmp);
1326 return $attachment_id;
1327 }
1328
1329 return array(
1330 'url' => wp_get_attachment_url($attachment_id),
1331 'attachment_id' => $attachment_id,
1332 );
1333 }
1334
1335 // ─── HTML Content Generation ────────────────────────────────────────
1336
1337 /**
1338 * Step 3: Generate the full HTML content
1339 */
1340 private function generate_html_content($plan, $image_urls, $content_type, $original_prompt = '', $custom_system_prompt = '') {
1341 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
1342 $has_images = !empty($image_urls);
1343
1344 // Build image URL map by section index
1345 $image_map = array();
1346 foreach ($image_urls as $img) {
1347 $image_map[$img['section_index']] = $img['url'];
1348 }
1349
1350 // Include image URLs in the plan for the AI
1351 $plan_with_images = $plan;
1352 foreach ($plan_with_images['sections'] as $index => &$section) {
1353 if (isset($image_map[$index])) {
1354 $section['image_url'] = $image_map[$index];
1355 }
1356 }
1357 unset($section);
1358
1359 // Strip image fields from plan when no images, so the AI doesn't
1360 // render image prompts as visible placeholder text
1361 if (!$has_images) {
1362 foreach ($plan_with_images['sections'] as &$section) {
1363 unset($section['needs_image'], $section['image_prompt']);
1364 }
1365 unset($section);
1366 }
1367
1368 $plan_json = wp_json_encode($plan_with_images, JSON_PRETTY_PRINT);
1369
1370 // Build explicit image URL reference so the AI can't miss them
1371 $image_reference = '';
1372 if ($has_images && !empty($image_map)) {
1373 $image_reference = "\n\n=== IMAGE URL REFERENCE (use these EXACT URLs) ===\n";
1374 foreach ($image_map as $section_idx => $url) {
1375 $section_heading = $plan['sections'][$section_idx]['heading'] ?? "Section {$section_idx}";
1376 $image_reference .= "Section \"{$section_heading}\": {$url}\n";
1377 }
1378 $image_reference .= "=== END IMAGE URLS — Do NOT use any other image URLs ===\n";
1379 }
1380
1381 // Check for custom prompt: passed directly, or saved in DB
1382 if (empty($custom_system_prompt)) {
1383 $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1384 $custom_system_prompt = get_option($option_key, '');
1385 }
1386
1387 if (!empty($custom_system_prompt)) {
1388 $system_prompt = $custom_system_prompt;
1389 } elseif ($content_type === 'page') {
1390 $system_prompt = $this->get_landing_page_prompt($has_images);
1391 } else {
1392 $system_prompt = $this->get_blog_post_prompt($has_images);
1393 }
1394
1395 // Allow add-ons to modify the system prompt (e.g. internal linking instructions)
1396 $system_prompt = apply_filters('mxchat_content_system_prompt', $system_prompt, $plan, $content_type, $template_mode);
1397
1398 // Include the original user prompt so the AI can see any specific URLs, links, or details the user mentioned
1399 $original_context = '';
1400 if (!empty($original_prompt)) {
1401 $original_context = "\n\n=== ORIGINAL USER REQUEST ===\n{$original_prompt}\n=== END ORIGINAL REQUEST ===\nIMPORTANT: If the user specified any URLs or links above, you MUST use those exact URLs in the corresponding buttons/links in the HTML. Do NOT replace user-specified URLs with href=\"#\".\n";
1402 }
1403
1404 $user_message = "Generate the full HTML content for this {$type_label}. Here is the content plan:\n\n{$plan_json}{$image_reference}{$original_context}";
1405
1406 // Allow add-ons to append data to the user message (e.g. internal links list)
1407 $user_message = apply_filters('mxchat_content_user_message', $user_message, $plan, $content_type);
1408
1409 $messages = array(
1410 array('role' => 'user', 'content' => $user_message),
1411 );
1412
1413 $result = $this->call_content_model($system_prompt, $messages, 16384);
1414
1415 if (is_wp_error($result)) {
1416 return $result;
1417 }
1418
1419 // Clean up - remove markdown code fences if present
1420 $result = trim($result);
1421 $result = preg_replace('/^```(?:html)?\s*/i', '', $result);
1422 $result = preg_replace('/\s*```\s*$/', '', $result);
1423
1424 // Strip HTML comments — WordPress wpautop() wraps them in <p> tags
1425 // causing visible white blocks like <p><!-- PRICING SECTION --></p>
1426 $result = preg_replace('/<!--.*?-->/s', '', $result);
1427
1428 // Post-process: replace any hallucinated image URLs with our real ones
1429 if ($has_images) {
1430 $result = $this->replace_hallucinated_images($result, $image_urls);
1431 }
1432
1433 // Allow add-ons to post-process generated HTML
1434 $result = apply_filters('mxchat_content_generated_html', $result, $plan, $content_type);
1435
1436 return $result;
1437 }
1438
1439 /**
1440 * Replace any image URL that isn't one of our provided real URLs.
1441 * AI models hallucinate fake URLs from random domains — instead of
1442 * blocklisting domains, we allowlist only the URLs we actually provided.
1443 */
1444 private function replace_hallucinated_images($html, $image_urls) {
1445 if (empty($image_urls)) {
1446 return $html;
1447 }
1448
1449 // Build set of our real URLs
1450 $real_urls = array();
1451 foreach ($image_urls as $img) {
1452 if (!empty($img['url'])) {
1453 $real_urls[] = $img['url'];
1454 }
1455 }
1456
1457 if (empty($real_urls)) {
1458 return $html;
1459 }
1460
1461 $index = 0;
1462
1463 $html = preg_replace_callback('/<img([^>]+)src=["\']([^"\']+)["\']([^>]*)>/i', function($matches) use ($real_urls, &$index) {
1464 $src = $matches[2];
1465
1466 // If this src is one of our real URLs, keep it
1467 if (in_array($src, $real_urls, true)) {
1468 return $matches[0];
1469 }
1470
1471 // Otherwise replace with the next real URL
1472 $replacement_url = $real_urls[$index % count($real_urls)];
1473 $index++;
1474 return '<img' . $matches[1] . 'src="' . esc_url($replacement_url) . '"' . $matches[3] . '>';
1475 }, $html);
1476
1477 return $html;
1478 }
1479
1480 /**
1481 * AJAX handler: return the default system prompt for the given content type.
1482 */
1483 public function handle_get_default_prompt() {
1484 check_ajax_referer('mxchat_content_nonce', 'nonce');
1485
1486 if (!current_user_can('manage_options')) {
1487 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
1488 }
1489
1490 $content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
1491
1492 if ($content_type === 'page') {
1493 $default = $this->get_landing_page_prompt(true);
1494 } else {
1495 $default = $this->get_blog_post_prompt(true);
1496 }
1497
1498 $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1499 $saved = get_option($option_key, '');
1500
1501 wp_send_json_success(array(
1502 'default_prompt' => $default,
1503 'saved_prompt' => $saved,
1504 ));
1505 }
1506
1507 /**
1508 * AJAX handler: save or reset a custom system prompt for a content type.
1509 */
1510 public function handle_save_custom_prompt() {
1511 check_ajax_referer('mxchat_content_nonce', 'nonce');
1512
1513 if (!current_user_can('manage_options')) {
1514 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
1515 }
1516
1517 $content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
1518 $custom_prompt = wp_unslash($_POST['custom_prompt'] ?? '');
1519 $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1520
1521 if (empty($custom_prompt)) {
1522 delete_option($option_key);
1523 } else {
1524 update_option($option_key, $custom_prompt, false);
1525 }
1526
1527 wp_send_json_success();
1528 }
1529
1530 /**
1531 * System prompt optimized for landing page generation (CSS-first approach).
1532 *
1533 * The AI outputs a <style> block with ALL CSS using mxg- prefixed classes,
1534 * followed by clean semantic HTML referencing those classes. No inline styles.
1535 */
1536 private function get_landing_page_prompt($has_images = false) {
1537 $image_rules = $has_images
1538 ? 'IMAGES:
1539 - You will receive image URLs in the content plan JSON under "image_url" for each section
1540 - You MUST use ONLY those exact image URLs — copy them character for character into your <img> tags
1541 - NEVER invent, guess, or fabricate ANY image URLs
1542 - If a section has no image_url, do NOT add an image for that section
1543 - Format: <img class="mxg-img" src="EXACT_URL_FROM_PLAN" alt="descriptive alt text">'
1544 : 'IMAGES:
1545 - Do NOT include any <img> tags at all
1546 - Do NOT add any images, placeholders, or image URLs
1547 - Design all sections using only text, colors, gradients, and layout';
1548
1549 return <<<PROMPT
1550 You are an expert web designer and copywriter. Generate a visually stunning, modern landing page.
1551
1552 YOUR OUTPUT FORMAT — you MUST follow this exactly:
1553 1. First, output a single <style> block containing ALL CSS for the page
1554 2. Then, output clean semantic HTML that references those CSS classes
1555 3. Return ONLY the <style> block followed by the HTML — no markdown, no code fences, no commentary
1556
1557 CSS RULES:
1558 - ALL class names MUST start with "mxg-" prefix (e.g. mxg-hero, mxg-card, mxg-btn-primary)
1559 - Do NOT use any inline styles on HTML elements — put ALL styling in the <style> block
1560 - Do NOT set font-family on anything (inherit from the WordPress theme)
1561 - Include responsive @media queries inside your <style> block:
1562 - @media (max-width: 768px) — tablet breakpoint (stack columns, reduce font sizes, adjust padding)
1563 - @media (max-width: 480px) — mobile breakpoint (further reduce sizes)
1564 - Use these required class names (we add responsive overrides for them):
1565 - mxg-container — inner content wrapper (max-width: 1200px; margin: 0 auto)
1566 - mxg-row — flex row for side-by-side layouts
1567 - mxg-col — each column in an mxg-row
1568 - mxg-grid — flex-wrap grid for cards
1569 - mxg-card — each card in an mxg-grid
1570 - mxg-hero-heading — the main h1 in the hero section
1571 - mxg-section-heading — h2 section headings
1572 - You may create additional mxg- classes as needed (e.g. mxg-hero, mxg-cta-section, mxg-btn-primary, mxg-subtitle, mxg-features)
1573
1574 DESIGN SYSTEM:
1575 - Wrap everything in <div class="mxg-wrapper">
1576 - Each <section> should be full-width with its own background color/gradient
1577 - Inside each section: <div class="mxg-container">
1578 - For side-by-side layouts: <div class="mxg-row"> with <div class="mxg-col"> children
1579 - For card grids: <div class="mxg-grid"> with <div class="mxg-card"> children
1580
1581 HERO SECTION:
1582 - Full-width dark or gradient background
1583 - Two-column layout (mxg-row): text column + visual/feature column
1584 - Large h1 with class="mxg-hero-heading" (3rem+ on desktop)
1585 - Subheading paragraph
1586 - CTA buttons (mxg-btn-primary, mxg-btn-secondary)
1587 - Generous vertical padding (80px+)
1588
1589 CONTENT SECTIONS:
1590 - Alternate light (#ffffff) and subtle gray (#f8fafc) backgrounds
1591 - Two-column layouts alternating content left/right
1592 - Card grids for features/benefits
1593 - Each section gets its own mxg- class for unique styling
1594 - Write substantive copy in every section — expand each plan key_point into a full sentence or short paragraph (roughly 80-180 words per content section). Do NOT condense the plan into one-line bullets
1595 - Vary the structure across sections: paragraphs, feature card grids, numbered steps, stat callouts, testimonial quotes, comparison tables, or FAQ blocks where they fit the content
1596
1597 TYPOGRAPHY:
1598 - Hero heading: 3rem desktop, scales down in your media queries
1599 - Section headings: 2.25rem desktop
1600 - Body text: 1.1rem, line-height 1.7
1601 - Light text on dark backgrounds (#e2e8f0), dark text on light backgrounds (#334155)
1602
1603 CTA SECTION (final):
1604 - Bold background color or gradient
1605 - Centered text with large heading
1606 - Prominent CTA button
1607
1608 {$image_rules}
1609
1610 CRITICAL RULES:
1611 - ALL styling goes in the <style> block — ZERO inline styles
1612 - ALL class names use the mxg- prefix — no unprefixed classes
1613 - The <style> block MUST include @media responsive queries
1614 - No shortcodes, no WordPress-specific markup, no page builder code
1615 - Use semantic HTML: section, h1-h3, p, a, div, img, ul, li, strong, em
1616 - Make the copy compelling, specific, and conversion-focused
1617 - If the user provided specific URLs/links in their request or in the plan's "links" array, you MUST use those exact URLs in the corresponding buttons and anchor tags
1618 - Only use href="#" as a fallback for links where no URL was specified by the user
1619 - Do NOT generate a <header>, <footer>, or <nav> — this goes INSIDE an existing WordPress page
1620 - Do NOT include HTML comments
1621 PROMPT;
1622 }
1623
1624 /**
1625 * System prompt optimized for blog post generation (CSS-first approach).
1626 *
1627 * The AI outputs a <style> block with ALL CSS using mxg- prefixed classes,
1628 * followed by clean semantic HTML referencing those classes. No inline styles.
1629 */
1630 private function get_blog_post_prompt($has_images = false) {
1631 $image_rules = $has_images
1632 ? 'IMAGES:
1633 - You will receive image URLs in the content plan JSON under "image_url" for each section
1634 - You MUST use ONLY those exact image URLs — copy them character for character into your <img> tags
1635 - NEVER invent, guess, or fabricate ANY image URLs
1636 - If a section has no image_url, do NOT add an image for that section
1637 - Format: <img class="mxg-img" src="EXACT_URL_FROM_PLAN" alt="descriptive alt text">
1638 - Place naturally within the content flow between sections'
1639 : 'IMAGES:
1640 - Do NOT include any <img> tags at all
1641 - Do NOT add any images, placeholders, or image URLs
1642 - Rely on text formatting, blockquotes, and takeaway boxes for visual interest';
1643
1644 return <<<PROMPT
1645 You are an expert content writer and web designer. Generate a beautifully formatted, long-form blog post.
1646
1647 YOUR OUTPUT FORMAT — you MUST follow this exactly:
1648 1. First, output a single <style> block containing ALL CSS for the post
1649 2. Then, output clean semantic HTML that references those CSS classes
1650 3. Return ONLY the <style> block followed by the HTML — no markdown, no code fences, no commentary
1651
1652 CSS RULES:
1653 - ALL class names MUST start with "mxg-" prefix (e.g. mxg-article, mxg-meta, mxg-blockquote, mxg-takeaway)
1654 - Do NOT use any inline styles on HTML elements — put ALL styling in the <style> block
1655 - Do NOT set font-family on anything (inherit from the WordPress theme)
1656 - Include responsive @media queries inside your <style> block:
1657 - @media (max-width: 768px) — tablet breakpoint
1658 - @media (max-width: 480px) — mobile breakpoint
1659 - Use these required class names (we add responsive overrides for them):
1660 - mxg-container — article wrapper (max-width: 800px; margin: 0 auto)
1661 - mxg-hero-heading — the main h1
1662 - mxg-section-heading — h2 section headings
1663 - You may create additional mxg- classes as needed (e.g. mxg-meta, mxg-blockquote, mxg-takeaway, mxg-highlight, mxg-img)
1664
1665 LAYOUT:
1666 - Wrap in <article class="mxg-container">
1667 - Clean, readable blog layout — single column, generous whitespace
1668 - max-width: 800px, centered, with comfortable padding
1669
1670 HEADER:
1671 - <h1 class="mxg-hero-heading"> — large, bold title (2.5rem desktop)
1672 - Meta line below: <p class="mxg-meta"> — publish date, estimated read time, subtle color
1673
1674 BODY CONTENT:
1675 - Write 1500-3000 words of genuinely useful, well-researched content
1676 - <h2 class="mxg-section-heading"> for main sections (1.75rem desktop)
1677 - H3 subheadings with their own mxg- class
1678 - Well-spaced paragraphs (1.1rem, line-height 1.8)
1679 - Varied structures: paragraphs, bullet lists, numbered lists, blockquotes, key takeaway boxes
1680
1681 SPECIAL ELEMENTS:
1682 - Blockquotes: <blockquote class="mxg-blockquote"> with left accent border, subtle background
1683 - Key takeaway boxes: <div class="mxg-takeaway"> with gradient background, border, rounded corners, bold heading inside
1684 - Highlighted stats: <span class="mxg-highlight"> with accent color and bold weight
1685
1686 {$image_rules}
1687
1688 CONCLUSION:
1689 - Clear summary section with H2 heading
1690 - Wrap up key points
1691 - End with a subtle CTA or next-steps suggestion
1692
1693 CRITICAL RULES:
1694 - ALL styling goes in the <style> block — ZERO inline styles
1695 - ALL class names use the mxg- prefix — no unprefixed classes
1696 - The <style> block MUST include @media responsive queries
1697 - No shortcodes, no WordPress-specific markup
1698 - Write substantive, expert-level content — not generic filler
1699 - Use semantic HTML: article, h1-h3, p, ul, ol, li, blockquote, img, strong, em, a, div, span
1700 - If the user provided specific URLs/links in their request or in the plan's "links" array, you MUST use those exact URLs in the corresponding anchor tags
1701 - Only use href="#" as a fallback for links where no URL was specified by the user (internal links to real posts will be provided separately if available)
1702 - Do NOT generate a <header>, <footer>, or <nav> — this goes INSIDE an existing WordPress page
1703 - Do NOT include HTML comments
1704 PROMPT;
1705 }
1706
1707 // ─── CSS Extraction ──────────────────────────────────────────────
1708
1709 /**
1710 * Extract CSS content from <style> tags in AI-generated HTML.
1711 * WordPress wp_kses strips <style> tags during sanitization,
1712 * so we pull the CSS out first and re-inject it after sanitizing.
1713 *
1714 * @param string $html Raw AI-generated HTML that may contain <style> blocks.
1715 * @return string The extracted CSS rules (without <style> tags), or empty string.
1716 */
1717 private function extract_css($html) {
1718 $css = '';
1719 if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $html, $matches)) {
1720 foreach ($matches[1] as $block) {
1721 $css .= trim($block) . "\n";
1722 }
1723 }
1724 return trim($css);
1725 }
1726
1727 /**
1728 * Build a self-contained <style> block to embed in post_content.
1729 * Combines all three CSS layers so the page renders correctly
1730 * even if the plugin is deactivated.
1731 */
1732 private function build_embedded_css($ai_css, $fullwidth = false, $hide_title = false) {
1733 $css = "<style>/* mxchat-css */\n";
1734
1735 // Layer 1: Fullwidth theme resets
1736 if ($fullwidth) {
1737 $css .= $this->get_fullwidth_reset_css();
1738 }
1739
1740 // Layer 2: mxg- isolation + responsive overrides
1741 $css .= $this->get_isolation_css();
1742
1743 // Layer 3: AI-generated CSS
1744 // Collapse blank lines to prevent wpautop from injecting <p> tags
1745 // inside the style block when the_content filter runs.
1746 if (!empty($ai_css)) {
1747 $clean_css = preg_replace('/\n\s*\n/', "\n", $ai_css);
1748 $css .= "\n/* MxChat — AI Generated Styles */\n" . $clean_css . "\n";
1749 }
1750
1751 // Title hiding
1752 if ($hide_title) {
1753 $css .= $this->get_title_hide_css();
1754 }
1755
1756 $css .= "</style>\n";
1757 return $css;
1758 }
1759
1760 // ─── Layout / Fullwidth Settings ──────────────────────────────────
1761
1762 /**
1763 * Build a single <style> block for legacy posts (CSS not yet embedded in post_content).
1764 * Used by inject_generated_css() as a backwards-compatibility fallback.
1765 */
1766 private function get_generated_css($fullwidth = true, $ai_css = '') {
1767 $css = '<style>' . "\n";
1768 if ($fullwidth) {
1769 $css .= $this->get_fullwidth_reset_css();
1770 }
1771 $css .= $this->get_isolation_css();
1772 if (!empty($ai_css)) {
1773 $css .= "\n/* MxChat — AI Generated Styles */\n" . $ai_css . "\n";
1774 }
1775 $css .= '</style>';
1776 return $css;
1777 }
1778
1779 /**
1780 * Layer 1: Theme & page builder fullwidth padding resets.
1781 */
1782 private function get_fullwidth_reset_css() {
1783 return '/* MxChat — Fullwidth Theme Reset */
1784 /* Generic WordPress themes */
1785 .entry-content-wrap,
1786 .entry-content,
1787 .post-inner .entry-content,
1788 .container.site-content,
1789 article .entry-content,
1790 .content-area .site-main,
1791 .single-content .entry-content,
1792 .type-post .entry-content,
1793 .type-page .entry-content,
1794 .page .entry-content,
1795 .single .entry-content {
1796 padding: 0 !important;
1797 max-width: 100% !important;
1798 width: 100% !important;
1799 }
1800 /* Outer wrappers that themes use to add spacing */
1801 .site-main > article,
1802 .content-area,
1803 .site-content,
1804 #content,
1805 #primary,
1806 .hentry,
1807 .post,
1808 .page .post,
1809 .single .post {
1810 padding: 0 !important;
1811 margin-left: 0 !important;
1812 margin-right: 0 !important;
1813 max-width: 100% !important;
1814 }
1815 /* Astra */
1816 .ast-container .entry-content,
1817 .site-content .ast-container,
1818 .ast-separate-container .ast-article-single,
1819 .ast-separate-container .ast-article-post,
1820 .ast-separate-container .ast-article-page {
1821 padding: 0 !important;
1822 margin: 0 auto !important;
1823 max-width: 100% !important;
1824 width: 100% !important;
1825 background: transparent !important;
1826 }
1827 .ast-separate-container .entry-content {
1828 margin: 0 !important;
1829 }
1830 /* GeneratePress */
1831 .inside-article .entry-content,
1832 .generate-columns-container,
1833 .inside-article {
1834 padding: 0 !important;
1835 max-width: 100% !important;
1836 width: 100% !important;
1837 }
1838 /* Kadence */
1839 .kb-row-layout-wrap,
1840 .entry-content-wrap,
1841 .content-container.site-container {
1842 padding: 0 !important;
1843 max-width: 100% !important;
1844 width: 100% !important;
1845 }
1846 .content-style-unboxed .entry:not(.loop-entry),
1847 .content-style-boxed .entry:not(.loop-entry) {
1848 box-shadow: none !important;
1849 border-radius: 0 !important;
1850 margin: 0 !important;
1851 padding: 0 !important;
1852 }
1853 /* OceanWP */
1854 .ocean-content .entry,
1855 #content-wrap .container {
1856 padding: 0 !important;
1857 max-width: 100% !important;
1858 width: 100% !important;
1859 }
1860 /* Neve */
1861 .nv-single-post-wrap .entry-content,
1862 .nv-content-wrap .entry-content {
1863 padding: 0 !important;
1864 max-width: 100% !important;
1865 width: 100% !important;
1866 }
1867 /* Hello Elementor / Elementor default theme */
1868 .site-main .elementor-section-wrap,
1869 .elementor-page .page-content .entry-content,
1870 .elementor-default .entry-content {
1871 padding: 0 !important;
1872 max-width: 100% !important;
1873 width: 100% !important;
1874 }
1875 /* Bricks Builder */
1876 .brxe-post-content .entry-content,
1877 .bricks-layout-wrapper .entry-content,
1878 .brxe-container .entry-content {
1879 padding: 0 !important;
1880 max-width: 100% !important;
1881 width: 100% !important;
1882 }
1883 /* Divi */
1884 .et_pb_post .entry-content,
1885 #main-content .container .entry-content,
1886 .et_full_width_page .entry-content {
1887 padding: 0 !important;
1888 max-width: 100% !important;
1889 width: 100% !important;
1890 }
1891 /* Beaver Builder */
1892 .fl-post-content .entry-content,
1893 .fl-content-full .entry-content {
1894 padding: 0 !important;
1895 max-width: 100% !important;
1896 width: 100% !important;
1897 }
1898 /* Blocksy */
1899 .entry-content[data-source],
1900 .site-main > article > .entry-content {
1901 padding: 0 !important;
1902 max-width: 100% !important;
1903 width: 100% !important;
1904 }
1905 /* Spectra / starter templates */
1906 .uagb-body-wrapper .entry-content,
1907 .starter-template-content .entry-content {
1908 padding: 0 !important;
1909 max-width: 100% !important;
1910 width: 100% !important;
1911 }
1912 /* WordPress Block Themes (Twenty Twenty-Two → Twenty Twenty-Five)
1913 These use theme.json layout constraints rather than classic .entry-content,
1914 so the selectors above do not apply — neutralize them here. */
1915 .wp-site-blocks,
1916 .wp-block-post-content,
1917 .wp-block-group.has-global-padding,
1918 .has-global-padding {
1919 padding-left: 0 !important;
1920 padding-right: 0 !important;
1921 max-width: 100% !important;
1922 }
1923 .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)),
1924 .wp-block-post-content > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
1925 max-width: 100% !important;
1926 margin-left: 0 !important;
1927 margin-right: 0 !important;
1928 }
1929 ';
1930 }
1931
1932 /**
1933 * Layer 2: CSS isolation + responsive overrides for mxg- classes.
1934 */
1935 private function get_isolation_css() {
1936 return '/* MxChat — CSS Isolation & Responsive Overrides */
1937 .mxg-wrapper { box-sizing: border-box; }
1938 .mxg-wrapper *, .mxg-wrapper *::before, .mxg-wrapper *::after { box-sizing: inherit; }
1939 .mxg-wrapper img { max-width: 100%; height: auto; }
1940 .mxg-wrapper section { clear: both; }
1941 .mxg-row { display: flex; flex-wrap: wrap; }
1942 .mxg-col { min-width: 0; }
1943 .mxg-grid { display: flex; flex-wrap: wrap; }
1944 .mxg-container { box-sizing: border-box; width: 100%; }
1945
1946 @media (max-width: 768px) {
1947 .mxg-row { flex-direction: column !important; gap: 24px !important; }
1948 .mxg-col { flex: 1 1 100% !important; width: 100% !important; max-width: 100% !important; }
1949 .mxg-hero-heading { font-size: 2.2rem !important; }
1950 .mxg-section-heading { font-size: 1.6rem !important; }
1951 .mxg-container { padding-left: 20px !important; padding-right: 20px !important; }
1952 .mxg-card { flex: 1 1 100% !important; }
1953 .mxg-grid { gap: 16px !important; }
1954 }
1955 @media (max-width: 480px) {
1956 .mxg-hero-heading { font-size: 1.75rem !important; }
1957 .mxg-section-heading { font-size: 1.35rem !important; }
1958 .mxg-container { padding-left: 16px !important; padding-right: 16px !important; }
1959 }
1960 ';
1961 }
1962
1963 /**
1964 * Title-hiding CSS for WordPress themes.
1965 */
1966 private function get_title_hide_css() {
1967 return '/* MxChat — Hide Title */
1968 .entry-title,
1969 .page-title,
1970 .post-title,
1971 .wp-block-post-title,
1972 .ast-title-with-post-meta-wrapper,
1973 .ast-the-title,
1974 .generate-page-header .page-hero,
1975 .entry-header .entry-title,
1976 .entry-hero .entry-title,
1977 .kadence-page-title,
1978 .wp-site-blocks .entry-title,
1979 .ocean-single-post-header,
1980 .page-header,
1981 .nv-page-title-wrap,
1982 .nv-post-title,
1983 .elementor-page-title,
1984 .et_pb_title_container .entry-title,
1985 .brxe-post-title,
1986 [data-hero] .page-title,
1987 .hero-section .page-title,
1988 .entry-header {
1989 display: none !important;
1990 }
1991 ';
1992 }
1993
1994 /**
1995 * Apply layout settings via theme-specific and builder-specific post meta.
1996 *
1997 * Supports: Astra, GeneratePress, Kadence, OceanWP, Neve, Blocksy,
1998 * Elementor, Bricks Builder, Divi, Beaver Builder, and generic WordPress.
1999 *
2000 * Page builders that are installed but NOT used to edit this post will
2001 * still respect standard WordPress post_content — this method sets the
2002 * right meta so the theme renders it fullwidth without sidebar.
2003 */
2004 private function apply_layout_settings($post_id, $layout, $title_display) {
2005 $is_fullwidth = ($layout === 'fullwidth');
2006 $hide_title = ($title_display === 'hide');
2007
2008 // ── Astra Theme ──
2009 if (defined('ASTRA_THEME_VERSION') || get_template() === 'astra') {
2010 if ($is_fullwidth) {
2011 update_post_meta($post_id, 'site-content-layout', 'page-builder');
2012 update_post_meta($post_id, 'site-sidebar-layout', 'no-sidebar');
2013 }
2014 if ($hide_title) {
2015 update_post_meta($post_id, 'site-post-title', 'disabled');
2016 }
2017 }
2018
2019 // ── GeneratePress Theme ──
2020 if (defined('GENERATE_VERSION') || get_template() === 'generatepress') {
2021 if ($is_fullwidth) {
2022 update_post_meta($post_id, '_generate-sidebar-layout-meta', 'no-sidebar');
2023 update_post_meta($post_id, '_generate-full-width-content', 'true');
2024 }
2025 if ($hide_title) {
2026 update_post_meta($post_id, '_generate-disable-title', 'true');
2027 }
2028 }
2029
2030 // ── Kadence Theme ──
2031 if (class_exists('Kadence\\Theme') || get_template() === 'kadence') {
2032 if ($is_fullwidth) {
2033 update_post_meta($post_id, '_kad_post_layout', 'fullwidth');
2034 update_post_meta($post_id, '_kad_post_content_style', 'unboxed');
2035 }
2036 if ($hide_title) {
2037 update_post_meta($post_id, '_kad_post_title', 'hide');
2038 }
2039 }
2040
2041 // ── OceanWP Theme ──
2042 if (class_exists('Ocean_Extra') || get_template() === 'oceanwp') {
2043 if ($is_fullwidth) {
2044 update_post_meta($post_id, 'oceanwp_post_layout', 'full-width');
2045 update_post_meta($post_id, 'ocean_content_layout', 'full-width');
2046 }
2047 if ($hide_title) {
2048 update_post_meta($post_id, 'oceanwp_disable_title', 'on');
2049 }
2050 }
2051
2052 // ── Neve Theme ──
2053 if (get_template() === 'neve') {
2054 if ($is_fullwidth) {
2055 update_post_meta($post_id, 'neve_meta_sidebar', 'full-width');
2056 update_post_meta($post_id, 'neve_meta_container', 'full-width');
2057 }
2058 if ($hide_title) {
2059 update_post_meta($post_id, 'neve_meta_disable_title', 'on');
2060 }
2061 }
2062
2063 // ── Blocksy Theme ──
2064 if (get_template() === 'blocksy') {
2065 if ($is_fullwidth) {
2066 update_post_meta($post_id, 'page_structure_type', 'type-4');
2067 }
2068 if ($hide_title) {
2069 update_post_meta($post_id, 'disable_header', 'yes');
2070 }
2071 }
2072
2073 // ── Elementor Canvas/Full Width ──
2074 // When Elementor is installed, use its Canvas template for the cleanest
2075 // fullwidth output (no header/footer/sidebar chrome from the theme).
2076 // The post still uses standard post_content — Elementor only takes over
2077 // rendering when _elementor_edit_mode is set (which we don't set).
2078 if (defined('ELEMENTOR_VERSION') && $is_fullwidth) {
2079 $post_type = get_post_type($post_id);
2080 $templates = wp_get_theme()->get_page_templates(get_post($post_id), $post_type);
2081
2082 // Prefer Elementor Canvas (no theme chrome at all)
2083 if (isset($templates['elementor_canvas'])) {
2084 update_post_meta($post_id, '_wp_page_template', 'elementor_canvas');
2085 } elseif (isset($templates['elementor_header_footer'])) {
2086 update_post_meta($post_id, '_wp_page_template', 'elementor_header_footer');
2087 }
2088 }
2089
2090 // ── Divi Theme / Divi Builder ──
2091 if (defined('ET_BUILDER_VERSION') || get_template() === 'Divi') {
2092 if ($is_fullwidth) {
2093 update_post_meta($post_id, '_et_pb_page_layout', 'et_full_width_page');
2094 update_post_meta($post_id, '_et_pb_side_nav', 'off');
2095 }
2096 if ($hide_title) {
2097 update_post_meta($post_id, '_et_pb_show_title', 'off');
2098 }
2099 }
2100
2101 // ── Beaver Builder ──
2102 if (class_exists('FLBuilder') || class_exists('FLBuilderLoader')) {
2103 if ($is_fullwidth) {
2104 // Beaver Themer uses this meta for sidebar control
2105 update_post_meta($post_id, '_fl_builder_sidebar', 'no_sidebar');
2106 }
2107 }
2108
2109 // ── Bricks Builder ──
2110 // Bricks uses its own rendering when _bricks_editor_mode is set.
2111 // For standard WP content, it falls through to the theme's template.
2112 // No special meta needed — our CSS resets and wp_head injection handle it.
2113
2114 // ── Generic full-width page template ──
2115 // Only set _wp_page_template if the theme actually has a matching template file
2116 // AND we haven't already set one above (e.g. Elementor Canvas).
2117 // Setting a non-existent template causes "Invalid page template" errors on wp_update_post().
2118 // Our CSS injection via wp_head already handles fullwidth layout for all themes.
2119 if ($is_fullwidth) {
2120 $current_template = get_post_meta($post_id, '_wp_page_template', true);
2121 if (empty($current_template) || $current_template === 'default') {
2122 $theme_templates = wp_get_theme()->get_page_templates(get_post($post_id));
2123 foreach ($theme_templates as $file => $label) {
2124 if (stripos($file, 'full') !== false && stripos($file, 'width') !== false) {
2125 update_post_meta($post_id, '_wp_page_template', $file);
2126 break;
2127 }
2128 }
2129 }
2130 }
2131 }
2132
2133 // ─── SEO Metadata ──────────────────────────────────────────────────
2134
2135 /**
2136 * Step 5: Fill SEO metadata for the generated post
2137 */
2138 private function fill_seo_metadata($post_id, $plan) {
2139 $meta_description = $plan['meta_description'] ?? '';
2140 $keywords = $plan['keywords'] ?? array();
2141 $focus_keyword = !empty($keywords) ? $keywords[0] : '';
2142 $keywords_string = implode(', ', $keywords);
2143
2144 $this->set_meta_description($post_id, $meta_description);
2145 $this->set_focus_keyword($post_id, !empty($focus_keyword) ? $focus_keyword : $keywords_string);
2146 }
2147
2148 // ─── AI Model Caller ────────────────────────────────────────────────
2149
2150 /**
2151 * Call the configured content model
2152 */
2153 private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
2154 $options = get_option('mxchat_options', array());
2155 $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest';
2156
2157 // Determine provider from model name
2158 if ($this->is_claude_model($model)) {
2159 return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
2160 } elseif ($this->is_gemini_model($model)) {
2161 return $this->call_gemini($model, $options['gemini_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
2162 } elseif ($this->is_xai_model($model)) {
2163 return $this->call_openai_compatible($model, $options['xai_api_key'] ?? '', 'https://api.x.ai/v1/chat/completions', $system_prompt, $messages, $max_tokens);
2164 } elseif ($this->is_deepseek_model($model)) {
2165 return $this->call_openai_compatible($model, $options['deepseek_api_key'] ?? '', 'https://api.deepseek.com/chat/completions', $system_prompt, $messages, $max_tokens);
2166 } else {
2167 // Default: OpenAI
2168 return $this->call_openai_compatible($model, $options['api_key'] ?? '', 'https://api.openai.com/v1/chat/completions', $system_prompt, $messages, $max_tokens);
2169 }
2170 }
2171
2172 /**
2173 * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
2174 */
2175 private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
2176 if (empty($api_key)) {
2177 return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
2178 }
2179
2180 $formatted = array();
2181 $formatted[] = array('role' => 'system', 'content' => $system_prompt);
2182 foreach ($messages as $msg) {
2183 $formatted[] = array(
2184 'role' => $msg['role'] ?? 'user',
2185 'content' => $msg['content'] ?? '',
2186 );
2187 }
2188
2189 // GPT-5.x models require max_completion_tokens and only support temperature=1
2190 $is_gpt5 = strpos($model, 'gpt-5') === 0;
2191 $token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens';
2192
2193 $body = array(
2194 'model' => $model,
2195 'messages' => $formatted,
2196 $token_key => $max_tokens,
2197 'stream' => false,
2198 );
2199
2200 if (!$is_gpt5) {
2201 $body['temperature'] = 0.7;
2202 }
2203
2204 // Add reasoning_effort only for GPT-5 models that support it
2205 // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
2206 if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') {
2207 // GPT-5.1 uses 'low' instead of 'minimal'
2208 if ($model === 'gpt-5.1-2025-11-13') {
2209 $body['reasoning_effort'] = 'low';
2210 } elseif ($model === 'gpt-5.4') {
2211 $body['reasoning_effort'] = 'low';
2212 } else {
2213 $body['reasoning_effort'] = 'minimal';
2214 }
2215 }
2216
2217 // Scale timeout with token count — large generation calls need more time
2218 $timeout = ($max_tokens > 8000) ? 300 : 120;
2219
2220 $response = wp_remote_post($endpoint, array(
2221 'headers' => array(
2222 'Authorization' => 'Bearer ' . $api_key,
2223 'Content-Type' => 'application/json',
2224 ),
2225 'body' => wp_json_encode($body),
2226 'timeout' => $timeout,
2227 ));
2228
2229 if (is_wp_error($response)) {
2230 return $response;
2231 }
2232
2233 $status_code = wp_remote_retrieve_response_code($response);
2234 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2235
2236 if ($status_code !== 200) {
2237 $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
2238 return new WP_Error('api_error', $error_msg);
2239 }
2240
2241 if (isset($decoded['choices'][0]['message']['content'])) {
2242 return trim($decoded['choices'][0]['message']['content']);
2243 }
2244
2245 return new WP_Error('unexpected_response', __('Unexpected API response format.', 'mxchat'));
2246 }
2247
2248 /**
2249 * Call Claude (Anthropic) API
2250 */
2251 private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
2252 if (empty($api_key)) {
2253 return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
2254 }
2255
2256 $formatted = array();
2257 foreach ($messages as $msg) {
2258 $role = $msg['role'] ?? 'user';
2259 if (!in_array($role, array('user', 'assistant'), true)) {
2260 $role = 'user';
2261 }
2262 $formatted[] = array(
2263 'role' => $role,
2264 'content' => $msg['content'] ?? '',
2265 );
2266 }
2267
2268 $body = array(
2269 'model' => $model,
2270 'max_tokens' => $max_tokens,
2271 'temperature' => 0.7,
2272 'messages' => $formatted,
2273 'system' => $system_prompt,
2274 );
2275
2276 $timeout = ($max_tokens > 8000) ? 300 : 120;
2277
2278 $response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
2279 'headers' => array(
2280 'Content-Type' => 'application/json',
2281 'x-api-key' => $api_key,
2282 'anthropic-version' => '2023-06-01',
2283 ),
2284 'body' => wp_json_encode($body),
2285 'timeout' => $timeout,
2286 ));
2287
2288 if (is_wp_error($response)) {
2289 return $response;
2290 }
2291
2292 $status_code = wp_remote_retrieve_response_code($response);
2293 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2294
2295 if ($status_code !== 200) {
2296 $error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code;
2297 return new WP_Error('claude_error', $error_msg);
2298 }
2299
2300 if (isset($decoded['content'][0]['text'])) {
2301 return trim($decoded['content'][0]['text']);
2302 }
2303
2304 return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat'));
2305 }
2306
2307
2308 /**
2309 * Call Gemini API
2310 */
2311 private function call_gemini($model, $api_key, $system_prompt, $messages, $max_tokens) {
2312 if (empty($api_key)) {
2313 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
2314 }
2315
2316 $formatted = array();
2317
2318 // System instructions as first user message
2319 $formatted[] = array(
2320 'role' => 'user',
2321 'parts' => array(array('text' => "[System Instructions] " . $system_prompt)),
2322 );
2323 $formatted[] = array(
2324 'role' => 'model',
2325 'parts' => array(array('text' => "I understand and will follow these instructions.")),
2326 );
2327
2328 foreach ($messages as $msg) {
2329 $role = ($msg['role'] ?? 'user') === 'assistant' ? 'model' : 'user';
2330 $formatted[] = array(
2331 'role' => $role,
2332 'parts' => array(array('text' => $msg['content'] ?? '')),
2333 );
2334 }
2335
2336 $body = array(
2337 'contents' => $formatted,
2338 'generationConfig' => array(
2339 'temperature' => 0.7,
2340 'topP' => 0.95,
2341 'topK' => 40,
2342 'maxOutputTokens' => $max_tokens,
2343 ),
2344 );
2345
2346 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2347 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key={$api_key}";
2348
2349 $timeout = ($max_tokens > 8000) ? 300 : 120;
2350
2351 $response = wp_remote_post($url, array(
2352 'headers' => array('Content-Type' => 'application/json'),
2353 'body' => wp_json_encode($body),
2354 'timeout' => $timeout,
2355 ));
2356
2357 if (is_wp_error($response)) {
2358 return $response;
2359 }
2360
2361 $status_code = wp_remote_retrieve_response_code($response);
2362 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2363
2364 if ($status_code !== 200) {
2365 $error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code;
2366 return new WP_Error('gemini_error', $error_msg);
2367 }
2368
2369 if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) {
2370 return trim($decoded['candidates'][0]['content']['parts'][0]['text']);
2371 }
2372
2373 return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat'));
2374 }
2375
2376 // ─── Model Detection Helpers ────────────────────────────────────────
2377
2378 private function is_claude_model($model) {
2379 return strpos($model, 'claude') === 0;
2380 }
2381
2382 private function is_gemini_model($model) {
2383 return strpos($model, 'gemini') === 0;
2384 }
2385
2386 private function is_xai_model($model) {
2387 return strpos($model, 'grok') === 0;
2388 }
2389
2390 private function is_deepseek_model($model) {
2391 return strpos($model, 'deepseek') === 0;
2392 }
2393
2394 // ─── Content Settings Save ────────────────────────────────────────
2395
2396 /**
2397 * Dedicated handler for saving content generator settings.
2398 * Bypasses the main settings handler entirely.
2399 */
2400 public function handle_save_content_setting() {
2401 check_ajax_referer('mxchat_content_nonce', 'nonce');
2402
2403 if (!current_user_can('manage_options')) {
2404 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2405 }
2406
2407 $field = sanitize_text_field($_POST['field'] ?? '');
2408 $value = sanitize_text_field($_POST['value'] ?? '');
2409
2410 $allowed_fields = array('content_model', 'content_image_model', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img');
2411 if (!in_array($field, $allowed_fields, true)) {
2412 wp_send_json_error(array('message' => __('Invalid field.', 'mxchat')));
2413 }
2414
2415 // Toggle fields
2416 if (in_array($field, array('content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img'), true)) {
2417 $value = ($value === 'on') ? 'on' : 'off';
2418 }
2419
2420 $options = get_option('mxchat_options', array());
2421 $options[$field] = $value;
2422 update_option('mxchat_options', $options);
2423
2424 wp_send_json_success(array('message' => __('Setting saved.', 'mxchat')));
2425 }
2426
2427 // ─── Progress Tracking ─────────────────────────────────────────────
2428
2429 /**
2430 * Update generation progress.
2431 * Uses wp_options directly (not transients) to avoid object cache
2432 * stale-read issues across the background worker and polling processes.
2433 */
2434 private function update_progress($key, $step, $message, $percent, $result = null) {
2435 global $wpdb;
2436
2437 $data = array(
2438 'step' => $step,
2439 'message' => $message,
2440 'percent' => $percent,
2441 'updated' => time(),
2442 );
2443 if ($result !== null) {
2444 $data['result'] = $result;
2445 }
2446
2447 $option_name = '_mxchat_progress_' . $key;
2448 $serialized = maybe_serialize($data);
2449
2450 // Direct DB write — bypasses object cache entirely
2451 $exists = $wpdb->get_var($wpdb->prepare(
2452 "SELECT COUNT(*) FROM $wpdb->options WHERE option_name = %s",
2453 $option_name
2454 ));
2455
2456 if ($exists) {
2457 $wpdb->update(
2458 $wpdb->options,
2459 array('option_value' => $serialized),
2460 array('option_name' => $option_name)
2461 );
2462 } else {
2463 $wpdb->insert(
2464 $wpdb->options,
2465 array(
2466 'option_name' => $option_name,
2467 'option_value' => $serialized,
2468 'autoload' => 'no',
2469 )
2470 );
2471 }
2472
2473 // Also bust the object cache in case anything reads via get_option()
2474 wp_cache_delete($option_name, 'options');
2475 }
2476
2477 /**
2478 * Read generation progress.
2479 * Direct DB read — bypasses object cache for guaranteed freshness.
2480 */
2481 private function get_progress($key) {
2482 global $wpdb;
2483
2484 $option_name = '_mxchat_progress_' . $key;
2485
2486 $value = $wpdb->get_var($wpdb->prepare(
2487 "SELECT option_value FROM $wpdb->options WHERE option_name = %s",
2488 $option_name
2489 ));
2490
2491 if ($value === null) {
2492 return false;
2493 }
2494
2495 return maybe_unserialize($value);
2496 }
2497
2498 /**
2499 * Delete generation progress (cleanup).
2500 */
2501 private function delete_progress($key) {
2502 global $wpdb;
2503
2504 $option_name = '_mxchat_progress_' . $key;
2505 $wpdb->delete($wpdb->options, array('option_name' => $option_name));
2506 wp_cache_delete($option_name, 'options');
2507 }
2508
2509
2510 // ─── Content History ──────────────────────────────────────────────
2511
2512 /**
2513 * Return paginated list of AI-generated posts for the History tab.
2514 */
2515 public function handle_content_history() {
2516 check_ajax_referer('mxchat_content_nonce', 'nonce');
2517
2518 if (!current_user_can('manage_options')) {
2519 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2520 }
2521
2522 $page = max(1, intval($_POST['page'] ?? 1));
2523 $per_page = 10;
2524
2525 $query = new WP_Query(array(
2526 'post_type' => array('post', 'page'),
2527 'post_status' => array('publish', 'draft', 'future', 'pending', 'private'),
2528 'meta_key' => '_mxchat_generated',
2529 'meta_value' => '1',
2530 'posts_per_page' => $per_page,
2531 'paged' => $page,
2532 'orderby' => 'date',
2533 'order' => 'DESC',
2534 ));
2535
2536 $items = array();
2537 foreach ($query->posts as $post) {
2538 $thumb = get_the_post_thumbnail_url($post->ID, 'thumbnail');
2539 $items[] = array(
2540 'post_id' => $post->ID,
2541 'title' => $post->post_title,
2542 'status' => $post->post_status,
2543 'post_type' => $post->post_type,
2544 'date' => get_the_date('M j, Y', $post),
2545 'thumbnail' => $thumb ? $thumb : '',
2546 'permalink' => get_permalink($post->ID),
2547 );
2548 }
2549
2550 wp_send_json_success(array(
2551 'items' => $items,
2552 'total' => (int) $query->found_posts,
2553 'total_pages' => (int) $query->max_num_pages,
2554 'current_page' => $page,
2555 ));
2556 }
2557
2558 /**
2559 * Move an AI-generated post to the trash.
2560 */
2561 public function handle_delete_content() {
2562 check_ajax_referer('mxchat_content_nonce', 'nonce');
2563
2564 if (!current_user_can('manage_options')) {
2565 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2566 }
2567
2568 $post_id = intval($_POST['post_id'] ?? 0);
2569 if (!$post_id) {
2570 wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
2571 }
2572
2573 // Only allow deleting MxChat-generated content
2574 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
2575 wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
2576 }
2577
2578 $result = wp_trash_post($post_id);
2579 if (!$result) {
2580 wp_send_json_error(array('message' => __('Failed to delete post.', 'mxchat')));
2581 }
2582
2583 wp_send_json_success(array('post_id' => $post_id));
2584 }
2585
2586 /**
2587 * Update the status of an AI-generated post (draft, publish, future).
2588 */
2589 public function handle_update_post_status() {
2590 check_ajax_referer('mxchat_content_nonce', 'nonce');
2591
2592 if (!current_user_can('manage_options')) {
2593 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2594 }
2595
2596 $post_id = intval($_POST['post_id'] ?? 0);
2597 $new_status = sanitize_text_field($_POST['new_status'] ?? '');
2598 $schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
2599
2600 if (!$post_id) {
2601 wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
2602 }
2603
2604 // Only allow updating MxChat-generated content
2605 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
2606 wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
2607 }
2608
2609 if (!in_array($new_status, array('draft', 'publish', 'future'), true)) {
2610 wp_send_json_error(array('message' => __('Invalid status.', 'mxchat')));
2611 }
2612
2613 $post_args = array('ID' => $post_id, 'post_status' => $new_status);
2614
2615 // Scheduled: require future date
2616 if ($new_status === 'future') {
2617 if (empty($schedule_date)) {
2618 wp_send_json_error(array('message' => __('A schedule date is required.', 'mxchat')));
2619 }
2620 $post_args['post_date'] = $schedule_date;
2621 $post_args['post_date_gmt'] = get_gmt_from_date($schedule_date);
2622 $post_args['edit_date'] = true;
2623 }
2624
2625 // Transitioning FROM future to draft/publish: reset post_date to now
2626 if ($new_status !== 'future') {
2627 $current = get_post($post_id);
2628 if ($current && $current->post_status === 'future') {
2629 $post_args['post_date'] = current_time('mysql');
2630 $post_args['post_date_gmt'] = current_time('mysql', true);
2631 $post_args['edit_date'] = true;
2632 }
2633 }
2634
2635 $result = wp_update_post($post_args, true);
2636 if (is_wp_error($result)) {
2637 wp_send_json_error(array('message' => $result->get_error_message()));
2638 }
2639
2640 // Re-read to get confirmed status (WP may auto-publish if schedule date is past)
2641 $updated = get_post($post_id);
2642
2643 wp_send_json_success(array(
2644 'post_id' => $post_id,
2645 'status' => $updated->post_status,
2646 ));
2647 }
2648
2649 /**
2650 * Load an existing AI-generated post into the editor state.
2651 * Returns the same data shape as the generation success response.
2652 */
2653 public function handle_load_post_for_edit() {
2654 check_ajax_referer('mxchat_content_nonce', 'nonce');
2655
2656 if (!current_user_can('manage_options')) {
2657 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2658 }
2659
2660 $post_id = intval($_POST['post_id'] ?? 0);
2661 if (!$post_id) {
2662 wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
2663 }
2664
2665 $post = get_post($post_id);
2666 if (!$post) {
2667 wp_send_json_error(array('message' => __('Post not found.', 'mxchat')));
2668 }
2669
2670 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
2671 wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
2672 }
2673
2674 $preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
2675 $edit_url = admin_url('post.php?post=' . $post_id . '&action=edit');
2676 $permalink = get_permalink($post_id);
2677
2678 // Discover images — prefer stored IDs, fall back to post_parent query
2679 $images = $this->discover_post_images($post_id);
2680
2681 wp_send_json_success(array(
2682 'post_id' => $post_id,
2683 'preview_url' => $preview_url,
2684 'edit_url' => $edit_url,
2685 'permalink' => $permalink,
2686 'title' => $post->post_title,
2687 'status' => $post->post_status,
2688 'images' => $images,
2689 'meta' => array(
2690 'description' => $this->get_meta_description($post_id),
2691 'keyword' => $this->get_focus_keyword($post_id),
2692 'excerpt' => $post->post_excerpt,
2693 ),
2694 ));
2695 }
2696
2697 /**
2698 * Discover images associated with a generated post.
2699 * Uses stored attachment IDs (most reliable), falls back to post_parent query.
2700 */
2701 private function discover_post_images($post_id) {
2702 $images = array();
2703 $stored_ids = get_post_meta($post_id, '_mxchat_image_ids', true);
2704
2705 if (!empty($stored_ids) && is_array($stored_ids)) {
2706 // Primary: use stored attachment IDs
2707 foreach ($stored_ids as $att_id) {
2708 $att_url = wp_get_attachment_url($att_id);
2709 if ($att_url) {
2710 $thumb_url = wp_get_attachment_image_url($att_id, 'medium');
2711 $images[] = array(
2712 'url' => $att_url,
2713 'thumbnail' => $thumb_url ?: $att_url,
2714 'attachment_id' => $att_id,
2715 );
2716 }
2717 }
2718 } else {
2719 // Fallback: query by post_parent + meta key
2720 $attachments = get_posts(array(
2721 'post_type' => 'attachment',
2722 'post_mime_type' => 'image',
2723 'posts_per_page' => -1,
2724 'post_parent' => $post_id,
2725 'meta_key' => '_mxchat_image_prompt',
2726 'meta_compare' => 'EXISTS',
2727 'orderby' => 'date',
2728 'order' => 'ASC',
2729 ));
2730 foreach ($attachments as $att) {
2731 $att_url = wp_get_attachment_url($att->ID);
2732 if ($att_url) {
2733 $thumb_url = wp_get_attachment_image_url($att->ID, 'medium');
2734 $images[] = array(
2735 'url' => $att_url,
2736 'thumbnail' => $thumb_url ?: $att_url,
2737 'attachment_id' => $att->ID,
2738 );
2739 }
2740 }
2741 }
2742
2743 // Fallback: include featured image if nothing else found
2744 $featured_id = get_post_thumbnail_id($post_id);
2745 if (empty($images) && $featured_id) {
2746 $feat_url = wp_get_attachment_url($featured_id);
2747 $feat_thumb = wp_get_attachment_image_url($featured_id, 'medium');
2748 if ($feat_url) {
2749 $images[] = array(
2750 'url' => $feat_url,
2751 'thumbnail' => $feat_thumb ?: $feat_url,
2752 'attachment_id' => $featured_id,
2753 );
2754 }
2755 }
2756
2757 return $images;
2758 }
2759
2760 /**
2761 * Get the focus keyword (first keyword from comma-separated list).
2762 */
2763 private function get_seo_plugin() {
2764 if (class_exists('RankMath')) return 'rankmath';
2765 if (defined('WPSEO_VERSION')) return 'yoast';
2766 if (function_exists('aioseo')) return 'aioseo';
2767 return 'none';
2768 }
2769
2770 private function get_meta_description($post_id) {
2771 $plugin = $this->get_seo_plugin();
2772 $keys = array(
2773 'rankmath' => 'rank_math_description',
2774 'yoast' => '_yoast_wpseo_metadesc',
2775 'aioseo' => '_aioseo_description',
2776 );
2777 if (isset($keys[$plugin])) {
2778 $val = get_post_meta($post_id, $keys[$plugin], true);
2779 if (!empty($val)) return $val;
2780 }
2781 return get_post_meta($post_id, '_mxchat_meta_description', true);
2782 }
2783
2784 private function set_meta_description($post_id, $value) {
2785 $value = sanitize_text_field($value);
2786 $plugin = $this->get_seo_plugin();
2787 $keys = array(
2788 'rankmath' => 'rank_math_description',
2789 'yoast' => '_yoast_wpseo_metadesc',
2790 'aioseo' => '_aioseo_description',
2791 );
2792 if (isset($keys[$plugin])) {
2793 update_post_meta($post_id, $keys[$plugin], $value);
2794 } else {
2795 update_post_meta($post_id, '_mxchat_meta_description', $value);
2796 }
2797 }
2798
2799 private function get_focus_keyword($post_id) {
2800 $plugin = $this->get_seo_plugin();
2801 $keys = array(
2802 'rankmath' => 'rank_math_focus_keyword',
2803 'yoast' => '_yoast_wpseo_focuskw',
2804 );
2805 if (isset($keys[$plugin])) {
2806 $val = get_post_meta($post_id, $keys[$plugin], true);
2807 if (!empty($val)) {
2808 $parts = explode(',', $val);
2809 return trim($parts[0]);
2810 }
2811 }
2812 $keywords = get_post_meta($post_id, '_mxchat_keywords', true);
2813 if (!empty($keywords)) {
2814 $parts = explode(',', $keywords);
2815 return trim($parts[0]);
2816 }
2817 return '';
2818 }
2819
2820 private function set_focus_keyword($post_id, $value) {
2821 $value = sanitize_text_field($value);
2822 $plugin = $this->get_seo_plugin();
2823 $keys = array(
2824 'rankmath' => 'rank_math_focus_keyword',
2825 'yoast' => '_yoast_wpseo_focuskw',
2826 );
2827 if (isset($keys[$plugin])) {
2828 update_post_meta($post_id, $keys[$plugin], $value);
2829 } else {
2830 update_post_meta($post_id, '_mxchat_keywords', $value);
2831 }
2832 }
2833
2834 // ─── SEO Analysis ──────────────────────────────────────────────
2835
2836 /**
2837 * Analyze a generated post for SEO quality.
2838 * Returns a 0-100 score with individual check results.
2839 */
2840 public function handle_seo_analyze() {
2841 check_ajax_referer('mxchat_content_nonce', 'nonce');
2842
2843 if (!current_user_can('edit_posts')) {
2844 wp_send_json_error('Unauthorized');
2845 }
2846
2847 $post_id = intval($_POST['post_id'] ?? 0);
2848 if (!$post_id || !get_post($post_id)) {
2849 wp_send_json_error('Post not found');
2850 }
2851
2852 $result = $this->seo_score_post($post_id);
2853 wp_send_json_success($result);
2854 }
2855
2856 public function handle_seo_analyze_batch() {
2857 check_ajax_referer('mxchat_content_nonce', 'nonce');
2858
2859 if (!current_user_can('edit_posts')) {
2860 wp_send_json_error('Unauthorized');
2861 }
2862
2863 $post_ids = array_map('intval', (array) ($_POST['post_ids'] ?? array()));
2864 $post_ids = array_filter($post_ids);
2865 if (empty($post_ids) || count($post_ids) > 50) {
2866 wp_send_json_error('Invalid post IDs (1-50 allowed)');
2867 }
2868
2869 $results = array();
2870 foreach ($post_ids as $pid) {
2871 if (get_post($pid)) {
2872 $results[$pid] = $this->seo_score_post($pid);
2873 }
2874 }
2875
2876 wp_send_json_success(array('results' => $results));
2877 }
2878
2879 private function seo_score_post($post_id) {
2880 $post = get_post($post_id);
2881
2882 $title = $post->post_title;
2883 $content = $post->post_content;
2884 $slug = $post->post_name;
2885 $meta_desc = $this->get_meta_description($post_id);
2886 $focus_kw = $this->get_focus_keyword($post_id);
2887 $text = wp_strip_all_tags($content);
2888 $word_count = str_word_count($text);
2889
2890 // Parse images
2891 preg_match_all('/<img[^>]*>/i', $content, $img_matches);
2892 $total_images = count($img_matches[0]);
2893 $images_with_alt = 0;
2894 foreach ($img_matches[0] as $img) {
2895 if (preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img, $alt_m) && trim($alt_m[1]) !== '') {
2896 $images_with_alt++;
2897 }
2898 }
2899
2900 // Parse links
2901 preg_match_all('/<a[^>]+href\s*=\s*["\']([^"\']+)["\']/i', $content, $link_matches);
2902 $site_url = home_url();
2903 $internal_links = 0;
2904 if (!empty($link_matches[1])) {
2905 foreach ($link_matches[1] as $href) {
2906 if (strpos($href, $site_url) === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) {
2907 $internal_links++;
2908 }
2909 }
2910 }
2911
2912 // Parse headings
2913 preg_match_all('/<h([1-6])[^>]*>/i', $content, $h_matches);
2914 $heading_count = count($h_matches[0]);
2915 $has_subheadings = false;
2916 foreach ($h_matches[1] as $lvl) {
2917 if ($lvl >= 2) { $has_subheadings = true; break; }
2918 }
2919
2920 $checks = array();
2921 $score = 100;
2922
2923 // 1. Title length
2924 $tl = mb_strlen($title);
2925 if ($tl === 0) $checks['title_length'] = array('status' => 'fail', 'label' => 'Page Title', 'detail' => 'Missing', 'penalty' => 20);
2926 elseif ($tl < 30) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — too short (aim for 50–60)', 'penalty' => 5);
2927 elseif ($tl > 70) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — may truncate in search', 'penalty' => 3);
2928 else $checks['title_length'] = array('status' => 'pass', 'label' => 'Page Title', 'detail' => $tl . ' chars — good length', 'penalty' => 0);
2929
2930 // 2. Meta description
2931 $ml = mb_strlen($meta_desc);
2932 if ($ml === 0) $checks['meta_desc'] = array('status' => 'fail', 'label' => 'Meta Description', 'detail' => 'Missing — engines will auto-generate one', 'penalty' => 15);
2933 elseif ($ml < 120) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — could be longer (150–160)', 'penalty' => 3);
2934 elseif ($ml > 160) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — may truncate (150–160)', 'penalty' => 3);
2935 else $checks['meta_desc'] = array('status' => 'pass', 'label' => 'Meta Description', 'detail' => $ml . ' chars — good length', 'penalty' => 0);
2936
2937 // 3. Focus keyword placement
2938 if (empty($focus_kw)) {
2939 $checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'Not set — helps guide optimization', 'penalty' => 5);
2940 } else {
2941 $places = array();
2942 if (mb_stripos($title, $focus_kw) !== false) $places[] = 'title';
2943 if (mb_stripos($meta_desc, $focus_kw) !== false) $places[] = 'meta';
2944 if (mb_stripos($text, $focus_kw) !== false) $places[] = 'content';
2945 if (stripos($slug, str_replace(' ', '-', strtolower($focus_kw))) !== false) $places[] = 'slug';
2946
2947 if (count($places) >= 3) $checks['focus_kw'] = array('status' => 'pass', 'label' => 'Focus Keyword', 'detail' => 'Found in ' . implode(', ', $places), 'penalty' => 0);
2948 elseif (count($places) >= 1) {
2949 $miss = array_diff(array('title', 'meta', 'content', 'slug'), $places);
2950 $checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'In ' . implode(', ', $places) . ' — missing from ' . implode(', ', array_slice($miss, 0, 2)), 'penalty' => 5);
2951 } else $checks['focus_kw'] = array('status' => 'fail', 'label' => 'Focus Keyword', 'detail' => '"' . esc_html($focus_kw) . '" not found in content', 'penalty' => 10);
2952 }
2953
2954 // 4. Content depth
2955 if ($word_count < 300) $checks['content_depth'] = array('status' => 'fail', 'label' => 'Content Depth', 'detail' => $word_count . ' words — thin (aim for 800+)', 'penalty' => 12);
2956 elseif ($word_count < 600) $checks['content_depth'] = array('status' => 'warn', 'label' => 'Content Depth', 'detail' => $word_count . ' words — light (800+ ideal)', 'penalty' => 5);
2957 else $checks['content_depth'] = array('status' => 'pass', 'label' => 'Content Depth', 'detail' => number_format($word_count) . ' words', 'penalty' => 0);
2958
2959 // 5. Heading structure
2960 if ($heading_count === 0) $checks['headings'] = array('status' => 'fail', 'label' => 'Heading Structure', 'detail' => 'No headings — add H2s to organize content', 'penalty' => 10);
2961 elseif (!$has_subheadings) $checks['headings'] = array('status' => 'warn', 'label' => 'Heading Structure', 'detail' => 'Missing subheadings (H2/H3)', 'penalty' => 5);
2962 else $checks['headings'] = array('status' => 'pass', 'label' => 'Heading Structure', 'detail' => $heading_count . ' headings — well structured', 'penalty' => 0);
2963
2964 // 6. Image ALT text
2965 if ($total_images === 0) {
2966 $checks['img_alt'] = array('status' => 'warn', 'label' => 'Image ALT Text', 'detail' => 'No images found', 'penalty' => 2);
2967 } else {
2968 $missing = $total_images - $images_with_alt;
2969 $checks['img_alt'] = $missing === 0
2970 ? array('status' => 'pass', 'label' => 'Image ALT Text', 'detail' => 'All ' . $total_images . ' images have ALT text', 'penalty' => 0)
2971 : array('status' => 'fail', 'label' => 'Image ALT Text', 'detail' => $missing . '/' . $total_images . ' missing ALT text', 'penalty' => min($missing * 3, 12));
2972 }
2973
2974 // 7. Internal links
2975 if ($internal_links === 0 && $word_count >= 300)
2976 $checks['internal_links'] = array('status' => 'fail', 'label' => 'Internal Links', 'detail' => 'None — link to related content', 'penalty' => 8);
2977 else
2978 $checks['internal_links'] = array('status' => 'pass', 'label' => 'Internal Links', 'detail' => $internal_links ? $internal_links . ' found' : 'Short content — optional', 'penalty' => 0);
2979
2980 // 8. Slug quality
2981 $sw = count(explode('-', $slug));
2982 if (strlen($slug) > 75) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => 'Too long — shorten to 3–5 words', 'penalty' => 3);
2983 elseif ($sw > 8) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => $sw . ' words — keep to 3–5', 'penalty' => 2);
2984 else $checks['slug'] = array('status' => 'pass', 'label' => 'URL Slug', 'detail' => '/' . esc_html($slug), 'penalty' => 0);
2985
2986 // 9. Readability (Flesch-Kincaid)
2987 $sents = max(count(preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY)), 1);
2988 $syls = $this->seo_count_syllables($text);
2989 $fk = max(0, min(100, round(206.835 - 1.015 * ($word_count / $sents) - 84.6 * ($syls / max($word_count, 1)))));
2990 if ($fk >= 60) $checks['readability'] = array('status' => 'pass', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — easy to read', 'penalty' => 0);
2991 elseif ($fk >= 40) $checks['readability'] = array('status' => 'warn', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — somewhat complex', 'penalty' => 3);
2992 else $checks['readability'] = array('status' => 'fail', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — hard to read, simplify', 'penalty' => 7);
2993
2994 // 10. Featured image
2995 $checks['featured_img'] = has_post_thumbnail($post_id)
2996 ? array('status' => 'pass', 'label' => 'Featured Image', 'detail' => 'Set', 'penalty' => 0)
2997 : array('status' => 'warn', 'label' => 'Featured Image', 'detail' => 'Missing — important for social sharing', 'penalty' => 4);
2998
2999 // Calculate score
3000 foreach ($checks as $c) { $score -= $c['penalty']; }
3001 $score = max(0, min(100, $score));
3002
3003 $pass = $warn = $fail = 0;
3004 foreach ($checks as $c) {
3005 if ($c['status'] === 'pass') $pass++;
3006 elseif ($c['status'] === 'warn') $warn++;
3007 else $fail++;
3008 }
3009
3010 // Cache results to post meta for the SEO dashboard list view
3011 update_post_meta($post_id, '_mxchat_seo_score', $score);
3012 update_post_meta($post_id, '_mxchat_seo_checks', $checks);
3013 update_post_meta($post_id, '_mxchat_seo_analyzed', time());
3014
3015 return array(
3016 'score' => $score,
3017 'checks' => $checks,
3018 'summary' => array('pass' => $pass, 'warn' => $warn, 'fail' => $fail),
3019 );
3020 }
3021
3022 /**
3023 * AI-powered SEO suggestion for a specific field.
3024 */
3025 public function handle_seo_suggest() {
3026 check_ajax_referer('mxchat_content_nonce', 'nonce');
3027 if (!current_user_can('edit_posts')) { wp_send_json_error('Unauthorized'); }
3028
3029 $post_id = intval($_POST['post_id'] ?? 0);
3030 $field = sanitize_text_field($_POST['field'] ?? '');
3031 if (!$post_id || !$field || !($post = get_post($post_id))) {
3032 wp_send_json_error('Missing parameters');
3033 }
3034
3035 $title = $post->post_title;
3036 $content = wp_strip_all_tags($post->post_content);
3037 $focus_kw = $this->get_focus_keyword($post_id);
3038
3039 // Sample content to manage token usage
3040 $sample = mb_strlen($content) > 1200
3041 ? mb_substr($content, 0, 800) . "\n...\n" . mb_substr($content, -400)
3042 : $content;
3043
3044 $kw = !empty($focus_kw) ? ' Incorporate the focus keyword "' . $focus_kw . '" naturally.' : '';
3045
3046 $prompts = array(
3047 'meta_description' => 'Write a compelling meta description for this blog post. 150-160 characters, include the main topic, entice clicks. Return ONLY the text.' . $kw . "\n\nTitle: " . $title . "\n\nContent:\n" . $sample,
3048 'seo_title' => 'Write an SEO-optimized page title. 50-60 characters, keyword near the beginning. Return ONLY the title.' . $kw . "\n\nOriginal: " . $title . "\n\nContent:\n" . $sample,
3049 'slug' => 'Generate an SEO-friendly URL slug. 3-5 lowercase words with hyphens, no stop words. Return ONLY the slug.' . $kw . "\n\nTitle: " . $title,
3050 'excerpt' => 'Write a concise excerpt in 1-2 sentences, under 200 characters. Return ONLY the text.' . $kw . "\n\nTitle: " . $title . "\n\nContent:\n" . $sample,
3051 'readability' => true, // Handled by Advanced Content Editor add-on
3052 'internal_links' => true, // Handled by Advanced Content Editor add-on
3053 'img_alt' => true, // Handled by Advanced Content Editor add-on
3054 'featured_img' => true, // Handled by Advanced Content Editor add-on
3055 );
3056
3057 if (!isset($prompts[$field])) { wp_send_json_error('Invalid field'); }
3058
3059 // These fields are handled by the Advanced Content Editor add-on
3060 $addon_fields = array('readability', 'internal_links', 'img_alt', 'featured_img');
3061 if (in_array($field, $addon_fields, true)) {
3062 $feature_key = 'seo_' . $field;
3063 $has_addon = apply_filters('mxchat_content_pro_feature', false, $feature_key);
3064 if (!$has_addon) {
3065 wp_send_json_error('This feature requires the Advanced Content Editor add-on.');
3066 return;
3067 }
3068 // Delegate to add-on via action hook
3069 do_action('mxchat_seo_optimize_' . $field, $post_id, $post, $focus_kw);
3070 return;
3071 }
3072
3073 $response = $this->call_content_model(
3074 'You are an expert SEO copywriter. Return only what is asked for. No quotes, no explanations, no prefixes.',
3075 array(array('role' => 'user', 'content' => $prompts[$field])),
3076 256
3077 );
3078
3079 if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); }
3080
3081 $suggestion = trim($response);
3082
3083 // Save suggestion
3084 if ($field === 'meta_description') {
3085 $this->set_meta_description($post_id, $suggestion);
3086 } elseif ($field === 'seo_title') {
3087 wp_update_post(array('ID' => $post_id, 'post_title' => sanitize_text_field($suggestion)));
3088 } elseif ($field === 'slug') {
3089 wp_update_post(array('ID' => $post_id, 'post_name' => sanitize_title($suggestion)));
3090 } elseif ($field === 'excerpt') {
3091 wp_update_post(array('ID' => $post_id, 'post_excerpt' => sanitize_text_field($suggestion)));
3092 }
3093
3094 wp_send_json_success(array('field' => $field, 'suggestion' => $suggestion));
3095 }
3096
3097 /**
3098 * List published posts/pages with cached SEO scores for the dashboard.
3099 */
3100 public function handle_seo_list_posts() {
3101 check_ajax_referer('mxchat_content_nonce', 'nonce');
3102
3103 $page = max(1, intval($_POST['page'] ?? 1));
3104 $per_page = 50;
3105 $post_type = sanitize_text_field($_POST['post_type'] ?? 'any');
3106 $filter = sanitize_text_field($_POST['filter'] ?? 'all');
3107 $search = sanitize_text_field($_POST['search'] ?? '');
3108 $sort_by = sanitize_text_field($_POST['sort_by'] ?? 'date');
3109 $sort_order = strtoupper(sanitize_text_field($_POST['sort_order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
3110
3111 // Map sort_by to WP_Query orderby
3112 $orderby = 'date';
3113 $sort_meta_key = '';
3114 switch ($sort_by) {
3115 case 'title':
3116 $orderby = 'title';
3117 break;
3118 case 'score':
3119 $orderby = 'meta_value_num';
3120 $sort_meta_key = '_mxchat_seo_score';
3121 break;
3122 case 'clicks':
3123 $orderby = 'meta_value_num';
3124 $sort_meta_key = '_mxchat_gsc_clicks';
3125 break;
3126 case 'impressions':
3127 $orderby = 'meta_value_num';
3128 $sort_meta_key = '_mxchat_gsc_impressions';
3129 break;
3130 default:
3131 $orderby = 'date';
3132 break;
3133 }
3134
3135 $args = array(
3136 'post_status' => 'publish',
3137 'posts_per_page' => -1,
3138 'post_type' => $post_type === 'any' ? array_values(array_diff(get_post_types(array('public' => true), 'names'), array('attachment'))) : $post_type,
3139 'orderby' => $orderby,
3140 'order' => $sort_order,
3141 'fields' => 'ids',
3142 );
3143
3144 if (!empty($search)) {
3145 $args['s'] = $search;
3146 }
3147
3148 // Meta query for score-based filters
3149 if ($filter === 'issues') {
3150 $args['meta_query'] = array(
3151 array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '<', 'type' => 'NUMERIC'),
3152 );
3153 } elseif ($filter === 'good') {
3154 $args['meta_query'] = array(
3155 array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '>=', 'type' => 'NUMERIC'),
3156 );
3157 } elseif ($filter === 'unscored') {
3158 $args['meta_query'] = array(
3159 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3160 );
3161 }
3162
3163 // When sorting by a meta field, ensure meta_key is set for ordering.
3164 // For 'all' filter, include posts without the meta key via OR clause.
3165 if ($sort_meta_key) {
3166 $args['meta_key'] = $sort_meta_key;
3167 if ($filter === 'all') {
3168 $args['meta_query'] = array(
3169 'relation' => 'OR',
3170 array('key' => $sort_meta_key, 'compare' => 'EXISTS'),
3171 array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'),
3172 );
3173 }
3174 }
3175
3176 $query = new \WP_Query($args);
3177 $all_ids = $query->posts;
3178 $total = count($all_ids);
3179 $pages = max(1, ceil($total / $per_page));
3180 $page = min($page, $pages);
3181 $offset = ($page - 1) * $per_page;
3182 $page_ids = array_slice($all_ids, $offset, $per_page);
3183
3184 $posts = array();
3185 foreach ($page_ids as $pid) {
3186 $p = get_post($pid);
3187 $score = get_post_meta($pid, '_mxchat_seo_score', true);
3188
3189 $gsc_clicks = get_post_meta($pid, '_mxchat_gsc_clicks', true);
3190 $gsc_impr = get_post_meta($pid, '_mxchat_gsc_impressions', true);
3191
3192 $posts[] = array(
3193 'id' => $pid,
3194 'title' => $p->post_title,
3195 'type' => $p->post_type,
3196 'date' => get_the_date('M j, Y', $pid),
3197 'edit_url' => get_edit_post_link($pid, 'raw'),
3198 'permalink' => get_permalink($pid),
3199 'score' => $score !== '' ? intval($score) : null,
3200 'analyzed' => (bool) get_post_meta($pid, '_mxchat_seo_analyzed', true),
3201 'clicks' => $gsc_clicks !== '' ? intval($gsc_clicks) : null,
3202 'impressions' => $gsc_impr !== '' ? intval($gsc_impr) : null,
3203 );
3204 }
3205
3206 // Count unscored for the Scan button
3207 $unscored_q = new \WP_Query(array(
3208 'post_status' => 'publish',
3209 'posts_per_page' => -1,
3210 'post_type' => array('post', 'page'),
3211 'fields' => 'ids',
3212 'meta_query' => array(
3213 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3214 ),
3215 ));
3216 $unscored_count = count($unscored_q->posts);
3217
3218 wp_send_json_success(array(
3219 'posts' => $posts,
3220 'page' => $page,
3221 'pages' => $pages,
3222 'total' => $total,
3223 'unscored_count' => $unscored_count,
3224 ));
3225 }
3226
3227 /**
3228 * Count syllables in text (Flesch-Kincaid helper).
3229 */
3230 private function seo_count_syllables($text) {
3231 $words = preg_split('/\s+/', strtolower($text), -1, PREG_SPLIT_NO_EMPTY);
3232 $total = 0;
3233 foreach ($words as $w) {
3234 $w = preg_replace('/[^a-z]/', '', $w);
3235 if (strlen($w) <= 3) { $total++; continue; }
3236 $w = preg_replace('/(?:[^laeiouy]es|ed|[^laeiouy]e)$/', '', $w);
3237 preg_match_all('/[aeiouy]{1,2}/', $w, $m);
3238 $total += max(1, count($m[0]));
3239 }
3240 return $total;
3241 }
3242 }