PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.1
MxChat – AI Chatbot & Content Generation for WordPress v3.1.1
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.1.1, at includes/class-mxchat-content-generator.php

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