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

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