PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
mxchat-basic / includes / class-mxchat-content-generator.php

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

3,459 lines 148.7 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 // Sync the inline `<img alt="...">` text from the generated body HTML
516 // into each attachment's _wp_attachment_image_alt postmeta so RankMath,
517 // schema markup, and the media library all see the alt text — not just
518 // the inline body HTML. (plan-mxchat-20260510-2be2da)
519 $this->sync_image_alts_from_content($full_content, $image_urls);
520
521 // Apply fullwidth/title settings via theme-specific post meta
522 $this->apply_layout_settings($post_id, $layout, $title_display);
523
524 // Step 5: Fill SEO metadata
525 $this->fill_seo_metadata($post_id, $plan);
526
527 // Build final result
528 $preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
529 $edit_url = admin_url('post.php?post=' . $post_id . '&action=edit');
530 $permalink = get_permalink($post_id);
531
532 $images_for_response = array();
533 foreach ($image_urls as $img) {
534 if (!empty($img['url']) && !empty($img['attachment_id'])) {
535 $thumb_url = wp_get_attachment_image_url($img['attachment_id'], 'medium');
536 $images_for_response[] = array(
537 'url' => $img['url'],
538 'thumbnail' => $thumb_url ?: $img['url'],
539 'attachment_id' => $img['attachment_id'],
540 );
541 }
542 }
543
544 $result = array(
545 'post_id' => $post_id,
546 'preview_url' => $preview_url,
547 'edit_url' => $edit_url,
548 'permalink' => $permalink,
549 'title' => $plan['title'],
550 'status' => $post_status,
551 'images' => $images_for_response,
552 'meta' => array(
553 'description' => $plan['meta_description'] ?? '',
554 'keyword' => !empty($plan['keywords']) ? $plan['keywords'][0] : '',
555 'excerpt' => '',
556 ),
557 );
558
559 // Store the result in the progress transient so polling can retrieve it
560 $this->update_progress($progress_key, 'done', __('Content generated successfully!', 'mxchat'), 100, $result);
561 }
562
563 /**
564 * Handle content edit via mini-chat
565 */
566 public function handle_content_edit() {
567 check_ajax_referer('mxchat_content_nonce', 'nonce');
568
569 if (!current_user_can('manage_options')) {
570 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
571 }
572
573 // Extend PHP execution time for long AI calls
574 if (function_exists('set_time_limit')) {
575 set_time_limit(300);
576 }
577
578 $post_id = intval($_POST['post_id'] ?? 0);
579 $edit_instruction = sanitize_textarea_field($_POST['edit_instruction'] ?? '');
580
581 if (!$post_id || empty($edit_instruction)) {
582 wp_send_json_error(array('message' => __('Missing post ID or edit instruction.', 'mxchat')));
583 }
584
585 $post = get_post($post_id);
586 if (!$post) {
587 wp_send_json_error(array('message' => __('Post not found.', 'mxchat')));
588 }
589
590 $current_content = $post->post_content;
591 $current_title = $post->post_title;
592 $current_css = get_post_meta($post_id, '_mxchat_content_css', true);
593
594 // Build the edit prompt — send both CSS and HTML so AI can edit either
595 $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>";
596
597 // Strip embedded boilerplate CSS from post_content (Layer 1/2 + marker)
598 // so the AI only sees the clean AI CSS + HTML
599 $clean_content = preg_replace('/<style>\/\* mxchat-css \*\/.*?<\/style>\s*/is', '', $current_content);
600
601 // Reconstruct: prepend only the AI CSS (from meta) + clean HTML
602 $full_content_for_ai = '';
603 if (!empty($current_css)) {
604 $full_content_for_ai = "<style>\n" . $current_css . "\n</style>\n";
605 }
606 $full_content_for_ai .= $clean_content;
607
608 $user_message = "Current post title: " . $current_title . "\n\nCurrent content (style block + HTML):\n" . $full_content_for_ai . "\n\nUser edit request: " . $edit_instruction;
609
610 $messages = array(
611 array('role' => 'user', 'content' => $user_message),
612 );
613
614 // Allow add-ons to handle the edit via tool calling (str_replace, etc.)
615 // Filter returns null to fall through to the default full-rewrite approach.
616 $result = apply_filters('mxchat_content_tool_edit', null, $full_content_for_ai, $edit_instruction, $current_title);
617
618 if ($result === null) {
619 // Default: AI rewrites entire page
620 $result = $this->call_content_model($system_prompt, $messages, 16384);
621 }
622
623 if (is_wp_error($result)) {
624 wp_send_json_error(array('message' => $result->get_error_message()));
625 }
626
627 // Clean up code fences if present
628 $result = trim($result);
629 $result = preg_replace('/^```(?:html)?\s*/i', '', $result);
630 $result = preg_replace('/\s*```\s*$/', '', $result);
631
632 // Strip HTML comments
633 $result = preg_replace('/<!--.*?-->/s', '', $result);
634
635 // Extract title if it was changed (look for first h1)
636 $new_title = $current_title;
637 if (preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $result, $title_match)) {
638 $new_title = wp_strip_all_tags($title_match[1]);
639 }
640
641 // Extract CSS → meta (for edit workflow), embed full CSS in post_content (for portability)
642 $ai_css = $this->extract_css($result);
643 $html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $result);
644 $sanitized_html = $this->sanitize_generated_html($html_without_style);
645
646 // Re-embed all CSS layers into post_content
647 $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1';
648 $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1';
649 $embedded_css = $this->build_embedded_css($ai_css, $fullwidth, $hide_title);
650 $full_content = $embedded_css . $sanitized_html;
651
652 // Clear invalid page template meta that causes "Invalid page template" errors
653 $current_template = get_post_meta($post_id, '_wp_page_template', true);
654 if (!empty($current_template) && $current_template !== 'default') {
655 $theme_templates = wp_get_theme()->get_page_templates(get_post($post_id));
656 if (!isset($theme_templates[$current_template])) {
657 delete_post_meta($post_id, '_wp_page_template');
658 }
659 }
660
661 // Update the post content (CSS + HTML) and meta
662 $update_result = wp_update_post(array(
663 'ID' => $post_id,
664 'post_title' => sanitize_text_field($new_title),
665 'post_content' => $full_content,
666 ), true);
667
668 // Keep CSS in meta for edit workflow reconstruction
669 update_post_meta($post_id, '_mxchat_content_css', $ai_css);
670
671 if (is_wp_error($update_result)) {
672 wp_send_json_error(array('message' => $update_result->get_error_message()));
673 }
674
675 $preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
676
677 // Discover images — prefer stored IDs, fall back to post_parent query
678 $images = $this->discover_post_images($post_id);
679
680 wp_send_json_success(array(
681 'post_id' => $post_id,
682 'preview_url' => $preview_url,
683 'title' => $new_title,
684 'message' => __('Content updated successfully.', 'mxchat'),
685 'images' => $images,
686 'meta' => array(
687 'description' => $this->get_meta_description($post_id),
688 'keyword' => $this->get_focus_keyword($post_id),
689 'excerpt' => get_post($post_id)->post_excerpt,
690 ),
691 ));
692 }
693
694 /**
695 * Return current progress status for polling
696 */
697 public function handle_content_progress() {
698 // Prevent proxy/CDN from caching progress responses
699 nocache_headers();
700
701 check_ajax_referer('mxchat_content_nonce', 'nonce');
702
703 $progress_key = sanitize_text_field($_POST['progress_key'] ?? '');
704 if (empty($progress_key)) {
705 wp_send_json_error(array('message' => __('Invalid progress key.', 'mxchat')));
706 }
707
708 // Direct DB read — bypasses all caching layers for guaranteed freshness
709 $progress = $this->get_progress($progress_key);
710 if (!$progress) {
711 wp_send_json_success(array(
712 'step' => 'waiting',
713 'message' => __('Waiting...', 'mxchat'),
714 'percent' => 0,
715 ));
716 return;
717 }
718
719 // Clean up completed/errored progress rows after reading
720 if (in_array($progress['step'], array('done', 'error'), true)) {
721 $this->delete_progress($progress_key);
722 }
723
724 wp_send_json_success($progress);
725 }
726
727 // ─── Content Planning ──────────────────────────────────────────────
728
729 /**
730 * Step 1: Ask AI to plan the content structure
731 */
732 private function plan_content($prompt, $content_type) {
733 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
734
735 // "Images per article" control (1–5, default 3). Drives how many sections the
736 // planner marks needs_image:true. When image generation is off, force zero.
737 $plan_options = get_option('mxchat_options', array());
738 $images_enabled = ($plan_options['content_enable_images'] ?? 'on') === 'on';
739 $image_count = max(1, min(5, (int) ($plan_options['content_image_count'] ?? 3)));
740 if ($images_enabled) {
741 $image_instruction = "- Select EXACTLY {$image_count} section(s) to illustrate: set \"needs_image\": true with a detailed \"image_prompt\" on exactly those {$image_count} section(s) and \"needs_image\": false with an empty \"image_prompt\" on every other section. Spread the illustrated sections across the article (favor the hero and the most visual content sections)";
742 } else {
743 $image_instruction = "- Set \"needs_image\": false and \"image_prompt\": \"\" on ALL sections (image generation is turned off for this site)";
744 }
745
746 $system_prompt = "You are a professional content strategist. The user wants to create a {$type_label}. Analyze their request and create a detailed content plan.\n\nYou MUST respond with ONLY valid JSON (no markdown, no code fences, no commentary). Use this exact structure:\n\n{\"title\": \"SEO-optimized title\", \"slug\": \"url-friendly-slug\", \"meta_description\": \"155 character meta description for SEO\", \"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"], \"links\": [{\"label\": \"Button or link text\", \"url\": \"https://example.com/page\"}], \"sections\": [{\"type\": \"hero\", \"heading\": \"Main heading\", \"subheading\": \"Supporting text\", \"needs_image\": true, \"image_prompt\": \"Detailed prompt for hero image\"}, {\"type\": \"content\", \"heading\": \"Section heading\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": true, \"image_prompt\": \"Detailed prompt for section image\"}, {\"type\": \"content\", \"heading\": \"Another section\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": false, \"image_prompt\": \"\"}, {\"type\": \"cta\", \"heading\": \"Call to action heading\", \"subheading\": \"CTA supporting text\", \"needs_image\": false, \"image_prompt\": \"\"}]}\n\nGuidelines:\n- Create 5-9 sections for blog posts, 7-11 for landing pages — add more if the user's request warrants extensive coverage\n- For landing pages, draw from sections like: hero, problem/solution, features, benefits, how-it-works, use cases, testimonials/social proof, pricing or comparison, FAQ, and final CTA — pick whichever fit the request\n- Each content section should include 3-5 substantive key_points (full descriptive sentences, not one-word labels) so the HTML generator has enough material to write meaningful copy\n{$image_instruction}\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 [].";
747
748 $messages = array(
749 array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"),
750 );
751
752 $result = $this->call_content_model($system_prompt, $messages);
753
754 if (is_wp_error($result)) {
755 return $result;
756 }
757
758 // Clean the response - remove markdown code fences if present
759 $result = trim($result);
760 $result = preg_replace('/^```(?:json)?\s*/i', '', $result);
761 $result = preg_replace('/\s*```\s*$/', '', $result);
762
763 $plan = json_decode($result, true);
764
765 if (json_last_error() !== JSON_ERROR_NONE) {
766 return new WP_Error('json_parse_error', __('Failed to parse content plan. The AI response was not valid JSON.', 'mxchat'));
767 }
768
769 // Validate required fields
770 if (empty($plan['title']) || empty($plan['sections'])) {
771 return new WP_Error('invalid_plan', __('Content plan is missing required fields (title or sections).', 'mxchat'));
772 }
773
774 return $plan;
775 }
776
777 // ─── Image Generation ──────────────────────────────────────────────
778
779 /**
780 * Step 2: Generate images for sections that need them.
781 * Uses cURL multi-handle to fire all image API requests in parallel,
782 * then saves results to the media library sequentially.
783 */
784 private function generate_content_images($plan, $progress_key) {
785 $options = get_option('mxchat_options', array());
786 $enable_images = ($options['content_enable_images'] ?? 'on') === 'on';
787
788 if (!$enable_images) {
789 return array();
790 }
791
792 $image_sections = array();
793 foreach ($plan['sections'] as $index => $section) {
794 if (!empty($section['needs_image']) && !empty($section['image_prompt'])) {
795 $image_sections[] = array(
796 'index' => $index,
797 'prompt' => $section['image_prompt'],
798 'heading' => $section['heading'] ?? 'Section',
799 );
800 }
801 }
802
803 // Safety cap: never generate more than the user-configured "Images per article"
804 // count (1–5, default 3), even if the planner over-marked needs_image sections.
805 $image_count = max(1, min(5, (int) ($options['content_image_count'] ?? 3)));
806 if (count($image_sections) > $image_count) {
807 $image_sections = array_slice($image_sections, 0, $image_count);
808 }
809
810 if (empty($image_sections)) {
811 return array();
812 }
813
814 $total = count($image_sections);
815
816 // Fallback to sequential if cURL multi is unavailable
817 if (!function_exists('curl_multi_init')) {
818 return $this->generate_content_images_sequential($image_sections, $total, $progress_key);
819 }
820
821 $this->update_progress(
822 $progress_key,
823 'images',
824 sprintf(__('Generating %d images...', 'mxchat'), $total),
825 30
826 );
827
828 // Build cURL requests for all images
829 $image_model = $options['content_image_model'] ?? 'gpt-image-1.5';
830 $curl_configs = array();
831
832 foreach ($image_sections as $img) {
833 $config = $this->build_image_request($img['prompt'], $image_model, $options);
834 if (!is_wp_error($config)) {
835 $curl_configs[] = array(
836 'section_index' => $img['index'],
837 'prompt' => $img['prompt'],
838 'config' => $config,
839 );
840 }
841 }
842
843 if (empty($curl_configs)) {
844 return array();
845 }
846
847 // Fire all requests in parallel
848 $multi = curl_multi_init();
849 $handles = array();
850
851 foreach ($curl_configs as $i => $item) {
852 $ch = curl_init();
853 curl_setopt($ch, CURLOPT_URL, $item['config']['url']);
854 curl_setopt($ch, CURLOPT_POST, true);
855 curl_setopt($ch, CURLOPT_POSTFIELDS, $item['config']['body']);
856 curl_setopt($ch, CURLOPT_HTTPHEADER, $item['config']['headers']);
857 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
858 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
859 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
860
861 curl_multi_add_handle($multi, $ch);
862 $handles[$i] = array(
863 'handle' => $ch,
864 'section_index' => $item['section_index'],
865 'prompt' => $item['prompt'],
866 );
867 }
868
869 // Execute all requests concurrently
870 $running = null;
871 do {
872 $status = curl_multi_exec($multi, $running);
873 if ($running > 0) {
874 curl_multi_select($multi, 1.0);
875 }
876 } while ($running > 0 && $status === CURLM_OK);
877
878 // Collect responses
879 $raw_responses = array();
880 foreach ($handles as $i => $h) {
881 $body = curl_multi_getcontent($h['handle']);
882 $http_code = curl_getinfo($h['handle'], CURLINFO_HTTP_CODE);
883
884 curl_multi_remove_handle($multi, $h['handle']);
885 curl_close($h['handle']);
886
887 if ($http_code === 200 && !empty($body)) {
888 $raw_responses[] = array(
889 'section_index' => $h['section_index'],
890 'prompt' => $h['prompt'],
891 'body' => $body,
892 );
893 }
894 }
895 curl_multi_close($multi);
896
897 $this->update_progress(
898 $progress_key,
899 'images',
900 __('Saving images to media library...', 'mxchat'),
901 50
902 );
903
904 // Process responses and save to media library (sequential — fast)
905 $image_urls = array();
906 foreach ($raw_responses as $resp) {
907 $result = $this->process_image_response($resp['body'], $image_model, $options);
908 if (!is_wp_error($result)) {
909 // Store the original prompt for later regeneration by add-ons
910 update_post_meta($result['attachment_id'], '_mxchat_image_prompt', $resp['prompt']);
911 $image_urls[] = array(
912 'section_index' => $resp['section_index'],
913 'url' => $result['url'],
914 'attachment_id' => $result['attachment_id'],
915 );
916 }
917 }
918
919 return $image_urls;
920 }
921
922 /**
923 * Sequential fallback when cURL multi is not available.
924 */
925 private function generate_content_images_sequential($image_sections, $total, $progress_key) {
926 $image_urls = array();
927
928 foreach ($image_sections as $i => $img) {
929 $step_num = $i + 1;
930 $this->update_progress(
931 $progress_key,
932 'images',
933 sprintf(__('Generating image %d of %d...', 'mxchat'), $step_num, $total),
934 30 + (int)(($step_num / $total) * 25)
935 );
936
937 $image_result = $this->generate_single_image($img['prompt']);
938
939 if (is_wp_error($image_result)) {
940 continue;
941 }
942
943 // Store the original prompt for later regeneration by add-ons
944 update_post_meta($image_result['attachment_id'], '_mxchat_image_prompt', $img['prompt']);
945
946 $image_urls[] = array(
947 'section_index' => $img['index'],
948 'url' => $image_result['url'],
949 'attachment_id' => $image_result['attachment_id'],
950 );
951 }
952
953 return $image_urls;
954 }
955
956 /**
957 * Generate a single image using the configured image model
958 */
959 private function generate_single_image($prompt) {
960 $options = get_option('mxchat_options', array());
961 $image_model = $options['content_image_model'] ?? 'gpt-image-1.5';
962
963 if (strpos($image_model, 'gpt-image') === 0) {
964 return $this->generate_openai_image($prompt, $options);
965 } elseif (strpos($image_model, 'grok') === 0) {
966 return $this->generate_xai_image($prompt, $options);
967 } elseif (strpos($image_model, 'gemini') === 0) {
968 return $this->generate_gemini_image($prompt, $image_model, $options);
969 }
970
971 return new WP_Error('unknown_model', __('Unknown image model configured.', 'mxchat'));
972 }
973
974 /**
975 * Build a cURL-ready request config for a single image generation.
976 * Used by the parallel image pipeline.
977 *
978 * @param string $prompt Image generation prompt.
979 * @param string $image_model The configured image model ID.
980 * @param array $options Plugin options (contains API keys).
981 * @return array|WP_Error Array with 'url', 'headers', 'body' keys, or WP_Error.
982 */
983 private function build_image_request($prompt, $image_model, $options) {
984 if (strpos($image_model, 'gpt-image') === 0) {
985 $api_key = $options['api_key'] ?? '';
986 if (empty($api_key)) {
987 return new WP_Error('no_api_key', __('OpenAI API key not configured.', 'mxchat'));
988 }
989 return array(
990 'url' => 'https://api.openai.com/v1/images/generations',
991 'headers' => array(
992 'Authorization: Bearer ' . $api_key,
993 'Content-Type: application/json',
994 ),
995 'body' => wp_json_encode(array(
996 'model' => 'gpt-image-1.5',
997 'prompt' => $prompt,
998 'n' => 1,
999 'size' => '1536x1024',
1000 'quality' => $options['content_image_quality'] ?? 'auto',
1001 'output_format' => 'png',
1002 )),
1003 );
1004 }
1005
1006 if (strpos($image_model, 'grok') === 0) {
1007 $api_key = $options['xai_api_key'] ?? '';
1008 if (empty($api_key)) {
1009 return new WP_Error('no_api_key', __('xAI API key not configured.', 'mxchat'));
1010 }
1011 return array(
1012 'url' => 'https://api.x.ai/v1/images/generations',
1013 'headers' => array(
1014 'Authorization: Bearer ' . $api_key,
1015 'Content-Type: application/json',
1016 ),
1017 'body' => wp_json_encode(array(
1018 'model' => $image_model,
1019 'prompt' => $prompt,
1020 'n' => 1,
1021 'response_format' => 'url',
1022 )),
1023 );
1024 }
1025
1026 if (strpos($image_model, 'gemini') === 0) {
1027 $api_key = $options['gemini_api_key'] ?? '';
1028 if (empty($api_key)) {
1029 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
1030 }
1031 $api_version = (strpos($image_model, 'preview') !== false) ? 'v1beta' : 'v1beta';
1032 return array(
1033 'url' => "https://generativelanguage.googleapis.com/{$api_version}/models/{$image_model}:generateContent?key={$api_key}",
1034 'headers' => array(
1035 'Content-Type: application/json',
1036 ),
1037 'body' => wp_json_encode(array(
1038 'contents' => array(
1039 array(
1040 'parts' => array(
1041 array('text' => $prompt),
1042 ),
1043 ),
1044 ),
1045 'generationConfig' => array(
1046 'responseModalities' => array('TEXT', 'IMAGE'),
1047 'imageConfig' => array('aspectRatio' => '16:9'),
1048 ),
1049 )),
1050 );
1051 }
1052
1053 return new WP_Error('unknown_model', __('Unknown image model.', 'mxchat'));
1054 }
1055
1056 /**
1057 * Process a raw API response body from an image generation call.
1058 * Decodes the response, extracts image data, and saves to media library.
1059 *
1060 * @param string $response_body Raw JSON response body.
1061 * @param string $image_model The image model used (determines response format).
1062 * @param array $options Plugin options.
1063 * @return array|WP_Error Array with 'url' and 'attachment_id', or WP_Error.
1064 */
1065 private function process_image_response($response_body, $image_model, $options = array()) {
1066 $decoded = json_decode($response_body, true);
1067 if (json_last_error() !== JSON_ERROR_NONE || empty($decoded)) {
1068 return new WP_Error('json_error', __('Invalid image API response.', 'mxchat'));
1069 }
1070
1071 // OpenAI GPT Image — b64_json or url
1072 if (strpos($image_model, 'gpt-image') === 0) {
1073 $b64_data = $decoded['data'][0]['b64_json'] ?? '';
1074 if (!empty($b64_data)) {
1075 return $this->save_image_to_media_library($b64_data, 'image/png');
1076 }
1077 $url = $decoded['data'][0]['url'] ?? '';
1078 if (!empty($url)) {
1079 return $this->save_image_url_to_media_library($url);
1080 }
1081 return new WP_Error('no_image_data', __('No image data in OpenAI response.', 'mxchat'));
1082 }
1083
1084 // xAI Grok — url
1085 if (strpos($image_model, 'grok') === 0) {
1086 $url = $decoded['data'][0]['url'] ?? '';
1087 if (!empty($url)) {
1088 return $this->save_image_url_to_media_library($url);
1089 }
1090 return new WP_Error('no_image_data', __('No image data in xAI response.', 'mxchat'));
1091 }
1092
1093 // Gemini — inlineData base64
1094 if (strpos($image_model, 'gemini') === 0) {
1095 if (isset($decoded['candidates'][0]['content']['parts'])) {
1096 foreach ($decoded['candidates'][0]['content']['parts'] as $part) {
1097 $inline_data = $part['inlineData'] ?? $part['inline_data'] ?? null;
1098 if ($inline_data && !empty($inline_data['data'])) {
1099 $mime_type = $inline_data['mimeType'] ?? $inline_data['mime_type'] ?? 'image/png';
1100 return $this->save_image_to_media_library($inline_data['data'], $mime_type);
1101 }
1102 }
1103 }
1104 return new WP_Error('no_image_data', __('No image data in Gemini response.', 'mxchat'));
1105 }
1106
1107 return new WP_Error('unknown_model', __('Unknown image model.', 'mxchat'));
1108 }
1109
1110 /**
1111 * Generate image via OpenAI GPT Image 1.5
1112 */
1113 private function generate_openai_image($prompt, $options) {
1114 $api_key = $options['api_key'] ?? '';
1115 if (empty($api_key)) {
1116 return new WP_Error('no_api_key', __('OpenAI API key not configured.', 'mxchat'));
1117 }
1118
1119 $body = array(
1120 'model' => 'gpt-image-1.5',
1121 'prompt' => $prompt,
1122 'n' => 1,
1123 'size' => '1536x1024',
1124 'quality' => $options['content_image_quality'] ?? 'auto',
1125 'output_format' => 'png',
1126 );
1127
1128 $response = wp_remote_post('https://api.openai.com/v1/images/generations', array(
1129 'headers' => array(
1130 'Authorization' => 'Bearer ' . $api_key,
1131 'Content-Type' => 'application/json',
1132 ),
1133 'body' => wp_json_encode($body),
1134 'timeout' => 120,
1135 ));
1136
1137 if (is_wp_error($response)) {
1138 return $response;
1139 }
1140
1141 $status_code = wp_remote_retrieve_response_code($response);
1142 $response_body = json_decode(wp_remote_retrieve_body($response), true);
1143
1144 if ($status_code !== 200) {
1145 $error_msg = $response_body['error']['message'] ?? __('OpenAI image generation failed.', 'mxchat');
1146 return new WP_Error('openai_image_error', $error_msg);
1147 }
1148
1149 // GPT Image returns b64_json by default
1150 $b64_data = $response_body['data'][0]['b64_json'] ?? '';
1151 if (!empty($b64_data)) {
1152 return $this->save_image_to_media_library($b64_data, 'image/png');
1153 }
1154
1155 // Fallback: URL-based response
1156 $image_url = $response_body['data'][0]['url'] ?? '';
1157 if (!empty($image_url)) {
1158 return $this->save_image_url_to_media_library($image_url);
1159 }
1160
1161 return new WP_Error('no_image_data', __('No image data in OpenAI response.', 'mxchat'));
1162 }
1163
1164 /**
1165 * Generate image via xAI Grok Imagine
1166 */
1167 private function generate_xai_image($prompt, $options) {
1168 $api_key = $options['xai_api_key'] ?? '';
1169 if (empty($api_key)) {
1170 return new WP_Error('no_api_key', __('xAI API key not configured.', 'mxchat'));
1171 }
1172
1173 $image_model = $options['content_image_model'] ?? 'grok-imagine-image';
1174
1175 $body = array(
1176 'model' => $image_model,
1177 'prompt' => $prompt,
1178 'n' => 1,
1179 'response_format' => 'url',
1180 );
1181
1182 $response = wp_remote_post('https://api.x.ai/v1/images/generations', array(
1183 'headers' => array(
1184 'Authorization' => 'Bearer ' . $api_key,
1185 'Content-Type' => 'application/json',
1186 ),
1187 'body' => wp_json_encode($body),
1188 'timeout' => 120,
1189 ));
1190
1191 if (is_wp_error($response)) {
1192 return $response;
1193 }
1194
1195 $status_code = wp_remote_retrieve_response_code($response);
1196 $response_body = json_decode(wp_remote_retrieve_body($response), true);
1197
1198 if ($status_code !== 200) {
1199 $error_msg = $response_body['error']['message'] ?? __('xAI image generation failed.', 'mxchat');
1200 return new WP_Error('xai_image_error', $error_msg);
1201 }
1202
1203 $image_url = $response_body['data'][0]['url'] ?? '';
1204 if (!empty($image_url)) {
1205 return $this->save_image_url_to_media_library($image_url);
1206 }
1207
1208 return new WP_Error('no_image_data', __('No image data in xAI response.', 'mxchat'));
1209 }
1210
1211 /**
1212 * Generate image via Gemini (Nano Banana)
1213 */
1214 private function generate_gemini_image($prompt, $model, $options) {
1215 $api_key = $options['gemini_api_key'] ?? '';
1216 if (empty($api_key)) {
1217 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
1218 }
1219
1220 $body = array(
1221 'contents' => array(
1222 array(
1223 'parts' => array(
1224 array('text' => $prompt),
1225 ),
1226 ),
1227 ),
1228 'generationConfig' => array(
1229 'responseModalities' => array('TEXT', 'IMAGE'),
1230 'imageConfig' => array(
1231 'aspectRatio' => '16:9',
1232 ),
1233 ),
1234 );
1235
1236 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
1237
1238 $response = wp_remote_post($url, array(
1239 'headers' => array(
1240 'Content-Type' => 'application/json',
1241 ),
1242 'body' => wp_json_encode($body),
1243 'timeout' => 120,
1244 ));
1245
1246 if (is_wp_error($response)) {
1247 return $response;
1248 }
1249
1250 $status_code = wp_remote_retrieve_response_code($response);
1251 $response_body = json_decode(wp_remote_retrieve_body($response), true);
1252
1253 if ($status_code !== 200) {
1254 $error_msg = $response_body['error']['message'] ?? __('Gemini image generation failed.', 'mxchat');
1255 return new WP_Error('gemini_image_error', $error_msg);
1256 }
1257
1258 // Extract image from Gemini response
1259 if (isset($response_body['candidates'][0]['content']['parts'])) {
1260 foreach ($response_body['candidates'][0]['content']['parts'] as $part) {
1261 $inline_data = $part['inlineData'] ?? $part['inline_data'] ?? null;
1262 if ($inline_data && !empty($inline_data['data'])) {
1263 $mime_type = $inline_data['mimeType'] ?? $inline_data['mime_type'] ?? 'image/png';
1264 return $this->save_image_to_media_library($inline_data['data'], $mime_type);
1265 }
1266 }
1267 }
1268
1269 return new WP_Error('no_image_data', __('No image data in Gemini response.', 'mxchat'));
1270 }
1271
1272 // ─── Media Library Helpers ──────────────────────────────────────────
1273
1274 /**
1275 * Save base64 image data to WordPress media library
1276 */
1277 private function save_image_to_media_library($base64_data, $mime_type = 'image/png') {
1278 require_once(ABSPATH . 'wp-admin/includes/media.php');
1279 require_once(ABSPATH . 'wp-admin/includes/file.php');
1280 require_once(ABSPATH . 'wp-admin/includes/image.php');
1281
1282 $image_content = base64_decode($base64_data);
1283 if ($image_content === false) {
1284 return new WP_Error('decode_failed', __('Failed to decode image data.', 'mxchat'));
1285 }
1286
1287 $extension = 'png';
1288 if (strpos($mime_type, 'jpeg') !== false || strpos($mime_type, 'jpg') !== false) {
1289 $extension = 'jpg';
1290 } elseif (strpos($mime_type, 'webp') !== false) {
1291 $extension = 'webp';
1292 }
1293
1294 $filename = 'mxchat-content-' . time() . '-' . wp_generate_password(6, false) . '.' . $extension;
1295 $temp_file = wp_tempnam($filename);
1296
1297 if (!$temp_file) {
1298 return new WP_Error('temp_file_failed', __('Could not create temporary file.', 'mxchat'));
1299 }
1300
1301 $bytes = file_put_contents($temp_file, $image_content);
1302 if ($bytes === false) {
1303 @unlink($temp_file);
1304 return new WP_Error('write_failed', __('Could not write image file.', 'mxchat'));
1305 }
1306
1307 $file_array = array(
1308 'name' => $filename,
1309 'tmp_name' => $temp_file,
1310 'type' => $mime_type,
1311 );
1312
1313 $attachment_id = media_handle_sideload($file_array, 0);
1314
1315 if (is_wp_error($attachment_id)) {
1316 @unlink($temp_file);
1317 return $attachment_id;
1318 }
1319
1320 return array(
1321 'url' => wp_get_attachment_url($attachment_id),
1322 'attachment_id' => $attachment_id,
1323 );
1324 }
1325
1326 /**
1327 * Download an image from URL and save to WordPress media library
1328 */
1329 private function save_image_url_to_media_library($image_url) {
1330 require_once(ABSPATH . 'wp-admin/includes/media.php');
1331 require_once(ABSPATH . 'wp-admin/includes/file.php');
1332 require_once(ABSPATH . 'wp-admin/includes/image.php');
1333
1334 $tmp = download_url($image_url, 120);
1335 if (is_wp_error($tmp)) {
1336 return $tmp;
1337 }
1338
1339 $filename = 'mxchat-content-' . time() . '-' . wp_generate_password(6, false) . '.jpg';
1340
1341 $file_array = array(
1342 'name' => $filename,
1343 'tmp_name' => $tmp,
1344 );
1345
1346 $attachment_id = media_handle_sideload($file_array, 0);
1347
1348 if (is_wp_error($attachment_id)) {
1349 @unlink($tmp);
1350 return $attachment_id;
1351 }
1352
1353 return array(
1354 'url' => wp_get_attachment_url($attachment_id),
1355 'attachment_id' => $attachment_id,
1356 );
1357 }
1358
1359 // ─── HTML Content Generation ────────────────────────────────────────
1360
1361 /**
1362 * Step 3: Generate the full HTML content
1363 */
1364 private function generate_html_content($plan, $image_urls, $content_type, $original_prompt = '', $custom_system_prompt = '') {
1365 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
1366 $has_images = !empty($image_urls);
1367
1368 // Build image URL map by section index
1369 $image_map = array();
1370 foreach ($image_urls as $img) {
1371 $image_map[$img['section_index']] = $img['url'];
1372 }
1373
1374 // Include image URLs in the plan for the AI
1375 $plan_with_images = $plan;
1376 foreach ($plan_with_images['sections'] as $index => &$section) {
1377 if (isset($image_map[$index])) {
1378 $section['image_url'] = $image_map[$index];
1379 }
1380 }
1381 unset($section);
1382
1383 // Strip image fields from plan when no images, so the AI doesn't
1384 // render image prompts as visible placeholder text
1385 if (!$has_images) {
1386 foreach ($plan_with_images['sections'] as &$section) {
1387 unset($section['needs_image'], $section['image_prompt']);
1388 }
1389 unset($section);
1390 }
1391
1392 $plan_json = wp_json_encode($plan_with_images, JSON_PRETTY_PRINT);
1393
1394 // Build explicit image URL reference so the AI can't miss them
1395 $image_reference = '';
1396 if ($has_images && !empty($image_map)) {
1397 $image_reference = "\n\n=== IMAGE URL REFERENCE (use these EXACT URLs) ===\n";
1398 foreach ($image_map as $section_idx => $url) {
1399 $section_heading = $plan['sections'][$section_idx]['heading'] ?? "Section {$section_idx}";
1400 $image_reference .= "Section \"{$section_heading}\": {$url}\n";
1401 }
1402 $image_reference .= "=== END IMAGE URLS — Do NOT use any other image URLs ===\n";
1403 }
1404
1405 // Check for custom prompt: passed directly, or saved in DB
1406 if (empty($custom_system_prompt)) {
1407 $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1408 $custom_system_prompt = get_option($option_key, '');
1409 }
1410
1411 if (!empty($custom_system_prompt)) {
1412 $system_prompt = $custom_system_prompt;
1413 } elseif ($content_type === 'page') {
1414 $system_prompt = $this->get_landing_page_prompt($has_images);
1415 } else {
1416 $system_prompt = $this->get_blog_post_prompt($has_images);
1417 }
1418
1419 // Allow add-ons to modify the system prompt (e.g. internal linking instructions)
1420 $system_prompt = apply_filters('mxchat_content_system_prompt', $system_prompt, $plan, $content_type, $template_mode);
1421
1422 // Include the original user prompt so the AI can see any specific URLs, links, or details the user mentioned
1423 $original_context = '';
1424 if (!empty($original_prompt)) {
1425 $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";
1426 }
1427
1428 $user_message = "Generate the full HTML content for this {$type_label}. Here is the content plan:\n\n{$plan_json}{$image_reference}{$original_context}";
1429
1430 // Allow add-ons to append data to the user message (e.g. internal links list)
1431 $user_message = apply_filters('mxchat_content_user_message', $user_message, $plan, $content_type);
1432
1433 $messages = array(
1434 array('role' => 'user', 'content' => $user_message),
1435 );
1436
1437 $result = $this->call_content_model($system_prompt, $messages, 16384);
1438
1439 if (is_wp_error($result)) {
1440 return $result;
1441 }
1442
1443 // Clean up - remove markdown code fences if present
1444 $result = trim($result);
1445 $result = preg_replace('/^```(?:html)?\s*/i', '', $result);
1446 $result = preg_replace('/\s*```\s*$/', '', $result);
1447
1448 // Strip HTML comments — WordPress wpautop() wraps them in <p> tags
1449 // causing visible white blocks like <p><!-- PRICING SECTION --></p>
1450 $result = preg_replace('/<!--.*?-->/s', '', $result);
1451
1452 // Post-process: replace any hallucinated image URLs with our real ones
1453 if ($has_images) {
1454 $result = $this->replace_hallucinated_images($result, $image_urls);
1455 }
1456
1457 // Allow add-ons to post-process generated HTML
1458 $result = apply_filters('mxchat_content_generated_html', $result, $plan, $content_type);
1459
1460 return $result;
1461 }
1462
1463 /**
1464 * Replace any image URL that isn't one of our provided real URLs.
1465 * AI models hallucinate fake URLs from random domains — instead of
1466 * blocklisting domains, we allowlist only the URLs we actually provided.
1467 */
1468 private function replace_hallucinated_images($html, $image_urls) {
1469 if (empty($image_urls)) {
1470 return $html;
1471 }
1472
1473 // Build set of our real URLs
1474 $real_urls = array();
1475 foreach ($image_urls as $img) {
1476 if (!empty($img['url'])) {
1477 $real_urls[] = $img['url'];
1478 }
1479 }
1480
1481 if (empty($real_urls)) {
1482 return $html;
1483 }
1484
1485 $index = 0;
1486
1487 $html = preg_replace_callback('/<img([^>]+)src=["\']([^"\']+)["\']([^>]*)>/i', function($matches) use ($real_urls, &$index) {
1488 $src = $matches[2];
1489
1490 // If this src is one of our real URLs, keep it
1491 if (in_array($src, $real_urls, true)) {
1492 return $matches[0];
1493 }
1494
1495 // Otherwise replace with the next real URL
1496 $replacement_url = $real_urls[$index % count($real_urls)];
1497 $index++;
1498 return '<img' . $matches[1] . 'src="' . esc_url($replacement_url) . '"' . $matches[3] . '>';
1499 }, $html);
1500
1501 return $html;
1502 }
1503
1504 /**
1505 * AJAX handler: return the default system prompt for the given content type.
1506 */
1507 public function handle_get_default_prompt() {
1508 check_ajax_referer('mxchat_content_nonce', 'nonce');
1509
1510 if (!current_user_can('manage_options')) {
1511 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
1512 }
1513
1514 $content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
1515
1516 if ($content_type === 'page') {
1517 $default = $this->get_landing_page_prompt(true);
1518 } else {
1519 $default = $this->get_blog_post_prompt(true);
1520 }
1521
1522 $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1523 $saved = get_option($option_key, '');
1524
1525 wp_send_json_success(array(
1526 'default_prompt' => $default,
1527 'saved_prompt' => $saved,
1528 ));
1529 }
1530
1531 /**
1532 * AJAX handler: save or reset a custom system prompt for a content type.
1533 */
1534 public function handle_save_custom_prompt() {
1535 check_ajax_referer('mxchat_content_nonce', 'nonce');
1536
1537 if (!current_user_can('manage_options')) {
1538 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
1539 }
1540
1541 $content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
1542 $custom_prompt = wp_unslash($_POST['custom_prompt'] ?? '');
1543 $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1544
1545 if (empty($custom_prompt)) {
1546 delete_option($option_key);
1547 } else {
1548 update_option($option_key, $custom_prompt, false);
1549 }
1550
1551 wp_send_json_success();
1552 }
1553
1554 /**
1555 * System prompt optimized for landing page generation (CSS-first approach).
1556 *
1557 * The AI outputs a <style> block with ALL CSS using mxg- prefixed classes,
1558 * followed by clean semantic HTML referencing those classes. No inline styles.
1559 */
1560 private function get_landing_page_prompt($has_images = false) {
1561 $image_rules = $has_images
1562 ? 'IMAGES:
1563 - You will receive image URLs in the content plan JSON under "image_url" for each section
1564 - You MUST use ONLY those exact image URLs — copy them character for character into your <img> tags
1565 - NEVER invent, guess, or fabricate ANY image URLs
1566 - If a section has no image_url, do NOT add an image for that section
1567 - Format: <img class="mxg-img" src="EXACT_URL_FROM_PLAN" alt="descriptive alt text">'
1568 : 'IMAGES:
1569 - Do NOT include any <img> tags at all
1570 - Do NOT add any images, placeholders, or image URLs
1571 - Design all sections using only text, colors, gradients, and layout';
1572
1573 return <<<PROMPT
1574 You are an expert web designer and copywriter. Generate a visually stunning, modern landing page.
1575
1576 YOUR OUTPUT FORMAT — you MUST follow this exactly:
1577 1. First, output a single <style> block containing ALL CSS for the page
1578 2. Then, output clean semantic HTML that references those CSS classes
1579 3. Return ONLY the <style> block followed by the HTML — no markdown, no code fences, no commentary
1580
1581 CSS RULES:
1582 - ALL class names MUST start with "mxg-" prefix (e.g. mxg-hero, mxg-card, mxg-btn-primary)
1583 - Do NOT use any inline styles on HTML elements — put ALL styling in the <style> block
1584 - Do NOT set font-family on anything (inherit from the WordPress theme)
1585 - Include responsive @media queries inside your <style> block:
1586 - @media (max-width: 768px) — tablet breakpoint (stack columns, reduce font sizes, adjust padding)
1587 - @media (max-width: 480px) — mobile breakpoint (further reduce sizes)
1588 - Use these required class names (we add responsive overrides for them):
1589 - mxg-container — inner content wrapper (max-width: 1200px; margin: 0 auto)
1590 - mxg-row — flex row for side-by-side layouts
1591 - mxg-col — each column in an mxg-row
1592 - mxg-grid — flex-wrap grid for cards
1593 - mxg-card — each card in an mxg-grid
1594 - mxg-hero-heading — the main h1 in the hero section
1595 - mxg-section-heading — h2 section headings
1596 - You may create additional mxg- classes as needed (e.g. mxg-hero, mxg-cta-section, mxg-btn-primary, mxg-subtitle, mxg-features)
1597
1598 DESIGN SYSTEM:
1599 - Wrap everything in <div class="mxg-wrapper">
1600 - Each <section> should be full-width with its own background color/gradient
1601 - Inside each section: <div class="mxg-container">
1602 - For side-by-side layouts: <div class="mxg-row"> with <div class="mxg-col"> children
1603 - For card grids: <div class="mxg-grid"> with <div class="mxg-card"> children
1604
1605 HERO SECTION:
1606 - Full-width dark or gradient background
1607 - Two-column layout (mxg-row): text column + visual/feature column
1608 - Large h1 with class="mxg-hero-heading" (3rem+ on desktop)
1609 - Subheading paragraph
1610 - CTA buttons (mxg-btn-primary, mxg-btn-secondary)
1611 - Generous vertical padding (80px+)
1612
1613 CONTENT SECTIONS:
1614 - Alternate light (#ffffff) and subtle gray (#f8fafc) backgrounds
1615 - Two-column layouts alternating content left/right
1616 - Card grids for features/benefits
1617 - Each section gets its own mxg- class for unique styling
1618 - Write substantive copy in every section — expand each plan key_point into a full sentence or short paragraph (roughly 80-180 words per content section). Do NOT condense the plan into one-line bullets
1619 - Vary the structure across sections: paragraphs, feature card grids, numbered steps, stat callouts, testimonial quotes, comparison tables, or FAQ blocks where they fit the content
1620
1621 TYPOGRAPHY:
1622 - Hero heading: 3rem desktop, scales down in your media queries
1623 - Section headings: 2.25rem desktop
1624 - Body text: 1.1rem, line-height 1.7
1625 - Light text on dark backgrounds (#e2e8f0), dark text on light backgrounds (#334155)
1626
1627 CTA SECTION (final):
1628 - Bold background color or gradient
1629 - Centered text with large heading
1630 - Prominent CTA button
1631
1632 {$image_rules}
1633
1634 CRITICAL RULES:
1635 - ALL styling goes in the <style> block — ZERO inline styles
1636 - ALL class names use the mxg- prefix — no unprefixed classes
1637 - The <style> block MUST include @media responsive queries
1638 - No shortcodes, no WordPress-specific markup, no page builder code
1639 - Use semantic HTML: section, h1-h3, p, a, div, img, ul, li, strong, em
1640 - Make the copy compelling, specific, and conversion-focused
1641 - 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
1642 - Only use href="#" as a fallback for links where no URL was specified by the user
1643 - Do NOT generate a <header>, <footer>, or <nav> — this goes INSIDE an existing WordPress page
1644 - Do NOT include HTML comments
1645 PROMPT;
1646 }
1647
1648 /**
1649 * System prompt optimized for blog post generation (CSS-first approach).
1650 *
1651 * The AI outputs a <style> block with ALL CSS using mxg- prefixed classes,
1652 * followed by clean semantic HTML referencing those classes. No inline styles.
1653 */
1654 private function get_blog_post_prompt($has_images = false) {
1655 $image_rules = $has_images
1656 ? 'IMAGES:
1657 - You will receive image URLs in the content plan JSON under "image_url" for each section
1658 - You MUST use ONLY those exact image URLs — copy them character for character into your <img> tags
1659 - NEVER invent, guess, or fabricate ANY image URLs
1660 - If a section has no image_url, do NOT add an image for that section
1661 - Format: <img class="mxg-img" src="EXACT_URL_FROM_PLAN" alt="descriptive alt text">
1662 - Place naturally within the content flow between sections'
1663 : 'IMAGES:
1664 - Do NOT include any <img> tags at all
1665 - Do NOT add any images, placeholders, or image URLs
1666 - Rely on text formatting, blockquotes, and takeaway boxes for visual interest';
1667
1668 return <<<PROMPT
1669 You are an expert content writer and web designer. Generate a beautifully formatted, long-form blog post.
1670
1671 YOUR OUTPUT FORMAT — you MUST follow this exactly:
1672 1. First, output a single <style> block containing ALL CSS for the post
1673 2. Then, output clean semantic HTML that references those CSS classes
1674 3. Return ONLY the <style> block followed by the HTML — no markdown, no code fences, no commentary
1675
1676 CSS RULES:
1677 - ALL class names MUST start with "mxg-" prefix (e.g. mxg-article, mxg-meta, mxg-blockquote, mxg-takeaway)
1678 - Do NOT use any inline styles on HTML elements — put ALL styling in the <style> block
1679 - Do NOT set font-family on anything (inherit from the WordPress theme)
1680 - Include responsive @media queries inside your <style> block:
1681 - @media (max-width: 768px) — tablet breakpoint
1682 - @media (max-width: 480px) — mobile breakpoint
1683 - Use these required class names (we add responsive overrides for them):
1684 - mxg-container — article wrapper (max-width: 800px; margin: 0 auto)
1685 - mxg-hero-heading — the main h1
1686 - mxg-section-heading — h2 section headings
1687 - You may create additional mxg- classes as needed (e.g. mxg-meta, mxg-blockquote, mxg-takeaway, mxg-highlight, mxg-img)
1688
1689 LAYOUT:
1690 - Wrap in <article class="mxg-container">
1691 - Clean, readable blog layout — single column, generous whitespace
1692 - max-width: 800px, centered, with comfortable padding
1693
1694 HEADER:
1695 - <h1 class="mxg-hero-heading"> — large, bold title (2.5rem desktop)
1696 - Meta line below: <p class="mxg-meta"> — publish date, estimated read time, subtle color
1697
1698 BODY CONTENT:
1699 - Write 1500-3000 words of genuinely useful, well-researched content
1700 - <h2 class="mxg-section-heading"> for main sections (1.75rem desktop)
1701 - H3 subheadings with their own mxg- class
1702 - Well-spaced paragraphs (1.1rem, line-height 1.8)
1703 - Varied structures: paragraphs, bullet lists, numbered lists, blockquotes, key takeaway boxes
1704
1705 SPECIAL ELEMENTS:
1706 - Blockquotes: <blockquote class="mxg-blockquote"> with left accent border, subtle background
1707 - Key takeaway boxes: <div class="mxg-takeaway"> with gradient background, border, rounded corners, bold heading inside
1708 - Highlighted stats: <span class="mxg-highlight"> with accent color and bold weight
1709
1710 {$image_rules}
1711
1712 CONCLUSION:
1713 - Clear summary section with H2 heading
1714 - Wrap up key points
1715 - End with a subtle CTA or next-steps suggestion
1716
1717 CRITICAL RULES:
1718 - ALL styling goes in the <style> block — ZERO inline styles
1719 - ALL class names use the mxg- prefix — no unprefixed classes
1720 - The <style> block MUST include @media responsive queries
1721 - No shortcodes, no WordPress-specific markup
1722 - Write substantive, expert-level content — not generic filler
1723 - Use semantic HTML: article, h1-h3, p, ul, ol, li, blockquote, img, strong, em, a, div, span
1724 - 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
1725 - 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)
1726 - Do NOT generate a <header>, <footer>, or <nav> — this goes INSIDE an existing WordPress page
1727 - Do NOT include HTML comments
1728 PROMPT;
1729 }
1730
1731 // ─── CSS Extraction ──────────────────────────────────────────────
1732
1733 /**
1734 * Extract CSS content from <style> tags in AI-generated HTML.
1735 * WordPress wp_kses strips <style> tags during sanitization,
1736 * so we pull the CSS out first and re-inject it after sanitizing.
1737 *
1738 * @param string $html Raw AI-generated HTML that may contain <style> blocks.
1739 * @return string The extracted CSS rules (without <style> tags), or empty string.
1740 */
1741 private function extract_css($html) {
1742 $css = '';
1743 if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $html, $matches)) {
1744 foreach ($matches[1] as $block) {
1745 $css .= trim($block) . "\n";
1746 }
1747 }
1748 return trim($css);
1749 }
1750
1751 /**
1752 * Build a self-contained <style> block to embed in post_content.
1753 * Combines all three CSS layers so the page renders correctly
1754 * even if the plugin is deactivated.
1755 */
1756 private function build_embedded_css($ai_css, $fullwidth = false, $hide_title = false) {
1757 $css = "<style>/* mxchat-css */\n";
1758
1759 // Layer 1: Fullwidth theme resets
1760 if ($fullwidth) {
1761 $css .= $this->get_fullwidth_reset_css();
1762 }
1763
1764 // Layer 2: mxg- isolation + responsive overrides
1765 $css .= $this->get_isolation_css();
1766
1767 // Layer 3: AI-generated CSS
1768 // Collapse blank lines to prevent wpautop from injecting <p> tags
1769 // inside the style block when the_content filter runs.
1770 if (!empty($ai_css)) {
1771 $clean_css = preg_replace('/\n\s*\n/', "\n", $ai_css);
1772 $css .= "\n/* MxChat — AI Generated Styles */\n" . $clean_css . "\n";
1773 }
1774
1775 // Title hiding
1776 if ($hide_title) {
1777 $css .= $this->get_title_hide_css();
1778 }
1779
1780 $css .= "</style>\n";
1781 return $css;
1782 }
1783
1784 // ─── Layout / Fullwidth Settings ──────────────────────────────────
1785
1786 /**
1787 * Build a single <style> block for legacy posts (CSS not yet embedded in post_content).
1788 * Used by inject_generated_css() as a backwards-compatibility fallback.
1789 */
1790 private function get_generated_css($fullwidth = true, $ai_css = '') {
1791 $css = '<style>' . "\n";
1792 if ($fullwidth) {
1793 $css .= $this->get_fullwidth_reset_css();
1794 }
1795 $css .= $this->get_isolation_css();
1796 if (!empty($ai_css)) {
1797 $css .= "\n/* MxChat — AI Generated Styles */\n" . $ai_css . "\n";
1798 }
1799 $css .= '</style>';
1800 return $css;
1801 }
1802
1803 /**
1804 * Layer 1: Theme & page builder fullwidth padding resets.
1805 */
1806 private function get_fullwidth_reset_css() {
1807 return '/* MxChat — Fullwidth Theme Reset */
1808 /* Generic WordPress themes */
1809 .entry-content-wrap,
1810 .entry-content,
1811 .post-inner .entry-content,
1812 .container.site-content,
1813 article .entry-content,
1814 .content-area .site-main,
1815 .single-content .entry-content,
1816 .type-post .entry-content,
1817 .type-page .entry-content,
1818 .page .entry-content,
1819 .single .entry-content {
1820 padding: 0 !important;
1821 max-width: 100% !important;
1822 width: 100% !important;
1823 }
1824 /* Outer wrappers that themes use to add spacing */
1825 .site-main > article,
1826 .content-area,
1827 .site-content,
1828 #content,
1829 #primary,
1830 .hentry,
1831 .post,
1832 .page .post,
1833 .single .post {
1834 padding: 0 !important;
1835 margin-left: 0 !important;
1836 margin-right: 0 !important;
1837 max-width: 100% !important;
1838 }
1839 /* Astra */
1840 .ast-container .entry-content,
1841 .site-content .ast-container,
1842 .ast-separate-container .ast-article-single,
1843 .ast-separate-container .ast-article-post,
1844 .ast-separate-container .ast-article-page {
1845 padding: 0 !important;
1846 margin: 0 auto !important;
1847 max-width: 100% !important;
1848 width: 100% !important;
1849 background: transparent !important;
1850 }
1851 .ast-separate-container .entry-content {
1852 margin: 0 !important;
1853 }
1854 /* GeneratePress */
1855 .inside-article .entry-content,
1856 .generate-columns-container,
1857 .inside-article {
1858 padding: 0 !important;
1859 max-width: 100% !important;
1860 width: 100% !important;
1861 }
1862 /* Kadence */
1863 .kb-row-layout-wrap,
1864 .entry-content-wrap,
1865 .content-container.site-container {
1866 padding: 0 !important;
1867 max-width: 100% !important;
1868 width: 100% !important;
1869 }
1870 .content-style-unboxed .entry:not(.loop-entry),
1871 .content-style-boxed .entry:not(.loop-entry) {
1872 box-shadow: none !important;
1873 border-radius: 0 !important;
1874 margin: 0 !important;
1875 padding: 0 !important;
1876 }
1877 /* OceanWP */
1878 .ocean-content .entry,
1879 #content-wrap .container {
1880 padding: 0 !important;
1881 max-width: 100% !important;
1882 width: 100% !important;
1883 }
1884 /* Neve */
1885 .nv-single-post-wrap .entry-content,
1886 .nv-content-wrap .entry-content {
1887 padding: 0 !important;
1888 max-width: 100% !important;
1889 width: 100% !important;
1890 }
1891 /* Hello Elementor / Elementor default theme */
1892 .site-main .elementor-section-wrap,
1893 .elementor-page .page-content .entry-content,
1894 .elementor-default .entry-content {
1895 padding: 0 !important;
1896 max-width: 100% !important;
1897 width: 100% !important;
1898 }
1899 /* Bricks Builder */
1900 .brxe-post-content .entry-content,
1901 .bricks-layout-wrapper .entry-content,
1902 .brxe-container .entry-content {
1903 padding: 0 !important;
1904 max-width: 100% !important;
1905 width: 100% !important;
1906 }
1907 /* Divi */
1908 .et_pb_post .entry-content,
1909 #main-content .container .entry-content,
1910 .et_full_width_page .entry-content {
1911 padding: 0 !important;
1912 max-width: 100% !important;
1913 width: 100% !important;
1914 }
1915 /* Beaver Builder */
1916 .fl-post-content .entry-content,
1917 .fl-content-full .entry-content {
1918 padding: 0 !important;
1919 max-width: 100% !important;
1920 width: 100% !important;
1921 }
1922 /* Blocksy */
1923 .entry-content[data-source],
1924 .site-main > article > .entry-content {
1925 padding: 0 !important;
1926 max-width: 100% !important;
1927 width: 100% !important;
1928 }
1929 /* Spectra / starter templates */
1930 .uagb-body-wrapper .entry-content,
1931 .starter-template-content .entry-content {
1932 padding: 0 !important;
1933 max-width: 100% !important;
1934 width: 100% !important;
1935 }
1936 /* WordPress Block Themes (Twenty Twenty-Two → Twenty Twenty-Five)
1937 These use theme.json layout constraints rather than classic .entry-content,
1938 so the selectors above do not apply — neutralize them here. */
1939 .wp-site-blocks,
1940 .wp-block-post-content,
1941 .wp-block-group.has-global-padding,
1942 .has-global-padding {
1943 padding-left: 0 !important;
1944 padding-right: 0 !important;
1945 max-width: 100% !important;
1946 }
1947 .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)),
1948 .wp-block-post-content > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
1949 max-width: 100% !important;
1950 margin-left: 0 !important;
1951 margin-right: 0 !important;
1952 }
1953 ';
1954 }
1955
1956 /**
1957 * Layer 2: CSS isolation + responsive overrides for mxg- classes.
1958 */
1959 private function get_isolation_css() {
1960 return '/* MxChat — CSS Isolation & Responsive Overrides */
1961 .mxg-wrapper { box-sizing: border-box; }
1962 .mxg-wrapper *, .mxg-wrapper *::before, .mxg-wrapper *::after { box-sizing: inherit; }
1963 .mxg-wrapper img { max-width: 100%; height: auto; }
1964 .mxg-wrapper section { clear: both; }
1965 .mxg-row { display: flex; flex-wrap: wrap; }
1966 .mxg-col { min-width: 0; }
1967 .mxg-grid { display: flex; flex-wrap: wrap; }
1968 .mxg-container { box-sizing: border-box; width: 100%; }
1969
1970 @media (max-width: 768px) {
1971 .mxg-row { flex-direction: column !important; gap: 24px !important; }
1972 .mxg-col { flex: 1 1 100% !important; width: 100% !important; max-width: 100% !important; }
1973 .mxg-hero-heading { font-size: 2.2rem !important; }
1974 .mxg-section-heading { font-size: 1.6rem !important; }
1975 .mxg-container { padding-left: 20px !important; padding-right: 20px !important; }
1976 .mxg-card { flex: 1 1 100% !important; }
1977 .mxg-grid { gap: 16px !important; }
1978 }
1979 @media (max-width: 480px) {
1980 .mxg-hero-heading { font-size: 1.75rem !important; }
1981 .mxg-section-heading { font-size: 1.35rem !important; }
1982 .mxg-container { padding-left: 16px !important; padding-right: 16px !important; }
1983 }
1984 ';
1985 }
1986
1987 /**
1988 * Title-hiding CSS for WordPress themes.
1989 */
1990 private function get_title_hide_css() {
1991 return '/* MxChat — Hide Title */
1992 .entry-title,
1993 .page-title,
1994 .post-title,
1995 .wp-block-post-title,
1996 .ast-title-with-post-meta-wrapper,
1997 .ast-the-title,
1998 .generate-page-header .page-hero,
1999 .entry-header .entry-title,
2000 .entry-hero .entry-title,
2001 .kadence-page-title,
2002 .wp-site-blocks .entry-title,
2003 .ocean-single-post-header,
2004 .page-header,
2005 .nv-page-title-wrap,
2006 .nv-post-title,
2007 .elementor-page-title,
2008 .et_pb_title_container .entry-title,
2009 .brxe-post-title,
2010 [data-hero] .page-title,
2011 .hero-section .page-title,
2012 .entry-header {
2013 display: none !important;
2014 }
2015 ';
2016 }
2017
2018 /**
2019 * Apply layout settings via theme-specific and builder-specific post meta.
2020 *
2021 * Supports: Astra, GeneratePress, Kadence, OceanWP, Neve, Blocksy,
2022 * Elementor, Bricks Builder, Divi, Beaver Builder, and generic WordPress.
2023 *
2024 * Page builders that are installed but NOT used to edit this post will
2025 * still respect standard WordPress post_content — this method sets the
2026 * right meta so the theme renders it fullwidth without sidebar.
2027 */
2028 private function apply_layout_settings($post_id, $layout, $title_display) {
2029 $is_fullwidth = ($layout === 'fullwidth');
2030 $hide_title = ($title_display === 'hide');
2031
2032 // ── Astra Theme ──
2033 if (defined('ASTRA_THEME_VERSION') || get_template() === 'astra') {
2034 if ($is_fullwidth) {
2035 update_post_meta($post_id, 'site-content-layout', 'page-builder');
2036 update_post_meta($post_id, 'site-sidebar-layout', 'no-sidebar');
2037 }
2038 if ($hide_title) {
2039 update_post_meta($post_id, 'site-post-title', 'disabled');
2040 }
2041 }
2042
2043 // ── GeneratePress Theme ──
2044 if (defined('GENERATE_VERSION') || get_template() === 'generatepress') {
2045 if ($is_fullwidth) {
2046 update_post_meta($post_id, '_generate-sidebar-layout-meta', 'no-sidebar');
2047 update_post_meta($post_id, '_generate-full-width-content', 'true');
2048 }
2049 if ($hide_title) {
2050 update_post_meta($post_id, '_generate-disable-title', 'true');
2051 }
2052 }
2053
2054 // ── Kadence Theme ──
2055 if (class_exists('Kadence\\Theme') || get_template() === 'kadence') {
2056 if ($is_fullwidth) {
2057 update_post_meta($post_id, '_kad_post_layout', 'fullwidth');
2058 update_post_meta($post_id, '_kad_post_content_style', 'unboxed');
2059 }
2060 if ($hide_title) {
2061 update_post_meta($post_id, '_kad_post_title', 'hide');
2062 }
2063 }
2064
2065 // ── OceanWP Theme ──
2066 if (class_exists('Ocean_Extra') || get_template() === 'oceanwp') {
2067 if ($is_fullwidth) {
2068 update_post_meta($post_id, 'oceanwp_post_layout', 'full-width');
2069 update_post_meta($post_id, 'ocean_content_layout', 'full-width');
2070 }
2071 if ($hide_title) {
2072 update_post_meta($post_id, 'oceanwp_disable_title', 'on');
2073 }
2074 }
2075
2076 // ── Neve Theme ──
2077 if (get_template() === 'neve') {
2078 if ($is_fullwidth) {
2079 update_post_meta($post_id, 'neve_meta_sidebar', 'full-width');
2080 update_post_meta($post_id, 'neve_meta_container', 'full-width');
2081 }
2082 if ($hide_title) {
2083 update_post_meta($post_id, 'neve_meta_disable_title', 'on');
2084 }
2085 }
2086
2087 // ── Blocksy Theme ──
2088 if (get_template() === 'blocksy') {
2089 if ($is_fullwidth) {
2090 update_post_meta($post_id, 'page_structure_type', 'type-4');
2091 }
2092 if ($hide_title) {
2093 update_post_meta($post_id, 'disable_header', 'yes');
2094 }
2095 }
2096
2097 // ── Elementor Canvas/Full Width ──
2098 // When Elementor is installed, use its Canvas template for the cleanest
2099 // fullwidth output (no header/footer/sidebar chrome from the theme).
2100 // The post still uses standard post_content — Elementor only takes over
2101 // rendering when _elementor_edit_mode is set (which we don't set).
2102 if (defined('ELEMENTOR_VERSION') && $is_fullwidth) {
2103 $post_type = get_post_type($post_id);
2104 $templates = wp_get_theme()->get_page_templates(get_post($post_id), $post_type);
2105
2106 // Prefer Elementor Canvas (no theme chrome at all)
2107 if (isset($templates['elementor_canvas'])) {
2108 update_post_meta($post_id, '_wp_page_template', 'elementor_canvas');
2109 } elseif (isset($templates['elementor_header_footer'])) {
2110 update_post_meta($post_id, '_wp_page_template', 'elementor_header_footer');
2111 }
2112 }
2113
2114 // ── Divi Theme / Divi Builder ──
2115 if (defined('ET_BUILDER_VERSION') || get_template() === 'Divi') {
2116 if ($is_fullwidth) {
2117 update_post_meta($post_id, '_et_pb_page_layout', 'et_full_width_page');
2118 update_post_meta($post_id, '_et_pb_side_nav', 'off');
2119 }
2120 if ($hide_title) {
2121 update_post_meta($post_id, '_et_pb_show_title', 'off');
2122 }
2123 }
2124
2125 // ── Beaver Builder ──
2126 if (class_exists('FLBuilder') || class_exists('FLBuilderLoader')) {
2127 if ($is_fullwidth) {
2128 // Beaver Themer uses this meta for sidebar control
2129 update_post_meta($post_id, '_fl_builder_sidebar', 'no_sidebar');
2130 }
2131 }
2132
2133 // ── Bricks Builder ──
2134 // Bricks uses its own rendering when _bricks_editor_mode is set.
2135 // For standard WP content, it falls through to the theme's template.
2136 // No special meta needed — our CSS resets and wp_head injection handle it.
2137
2138 // ── Generic full-width page template ──
2139 // Only set _wp_page_template if the theme actually has a matching template file
2140 // AND we haven't already set one above (e.g. Elementor Canvas).
2141 // Setting a non-existent template causes "Invalid page template" errors on wp_update_post().
2142 // Our CSS injection via wp_head already handles fullwidth layout for all themes.
2143 if ($is_fullwidth) {
2144 $current_template = get_post_meta($post_id, '_wp_page_template', true);
2145 if (empty($current_template) || $current_template === 'default') {
2146 $theme_templates = wp_get_theme()->get_page_templates(get_post($post_id));
2147 foreach ($theme_templates as $file => $label) {
2148 if (stripos($file, 'full') !== false && stripos($file, 'width') !== false) {
2149 update_post_meta($post_id, '_wp_page_template', $file);
2150 break;
2151 }
2152 }
2153 }
2154 }
2155 }
2156
2157 // ─── SEO Metadata ──────────────────────────────────────────────────
2158
2159 /**
2160 * Step 5: Fill SEO metadata for the generated post
2161 */
2162 private function fill_seo_metadata($post_id, $plan) {
2163 $meta_description = $plan['meta_description'] ?? '';
2164 $keywords = $plan['keywords'] ?? array();
2165 $focus_keyword = !empty($keywords) ? $keywords[0] : '';
2166 $keywords_string = implode(', ', $keywords);
2167
2168 $this->set_meta_description($post_id, $meta_description);
2169 $this->set_focus_keyword($post_id, !empty($focus_keyword) ? $focus_keyword : $keywords_string);
2170 $this->set_seo_title($post_id, $plan['title'] ?? '');
2171 }
2172
2173 /**
2174 * Write the SEO title to the active SEO plugin's title postmeta so
2175 * RankMath / Yoast / AIOSEO don't fall back to the bare post_title.
2176 * (plan-mxchat-20260510-b89332)
2177 *
2178 * v1: use the generated post_title as the SEO title. A future plan may
2179 * add a separately-generated 50-58 char SEO-optimized title.
2180 */
2181 private function set_seo_title($post_id, $title) {
2182 $title = sanitize_text_field($title);
2183 if (empty($title)) {
2184 return;
2185 }
2186 $plugin = $this->get_seo_plugin();
2187 $keys = array(
2188 'rankmath' => 'rank_math_title',
2189 'yoast' => '_yoast_wpseo_title',
2190 'aioseo' => '_aioseo_title',
2191 );
2192 if (isset($keys[$plugin])) {
2193 update_post_meta($post_id, $keys[$plugin], $title);
2194 }
2195 }
2196
2197 /**
2198 * Walk the rendered body HTML and copy the inline `<img alt="...">` text
2199 * for each generated image into its attachment's _wp_attachment_image_alt
2200 * postmeta. Matches inline images to attachments by URL.
2201 * (plan-mxchat-20260510-2be2da)
2202 */
2203 private function sync_image_alts_from_content($content, $image_urls) {
2204 if (empty($content) || empty($image_urls)) {
2205 return;
2206 }
2207
2208 // Build a url => attachment_id lookup for fast matching.
2209 $by_url = array();
2210 foreach ($image_urls as $img) {
2211 if (!empty($img['url']) && !empty($img['attachment_id'])) {
2212 $by_url[$img['url']] = (int) $img['attachment_id'];
2213 }
2214 }
2215 if (empty($by_url)) {
2216 return;
2217 }
2218
2219 if (!preg_match_all('/<img\b[^>]*>/i', $content, $matches)) {
2220 return;
2221 }
2222
2223 foreach ($matches[0] as $img_tag) {
2224 if (!preg_match('/\bsrc\s*=\s*"([^"]+)"/i', $img_tag, $src_match)) {
2225 continue;
2226 }
2227 $src = html_entity_decode($src_match[1], ENT_QUOTES);
2228 if (!isset($by_url[$src])) {
2229 continue;
2230 }
2231 if (!preg_match('/\balt\s*=\s*"([^"]*)"/i', $img_tag, $alt_match)) {
2232 continue;
2233 }
2234 $alt = sanitize_text_field(html_entity_decode($alt_match[1], ENT_QUOTES));
2235 if ($alt !== '') {
2236 update_post_meta($by_url[$src], '_wp_attachment_image_alt', $alt);
2237 }
2238 }
2239 }
2240
2241 // ─── AI Model Caller ────────────────────────────────────────────────
2242
2243 /**
2244 * Call the configured content model
2245 */
2246 /**
2247 * Provider slugs (MxChat_Model_Catalog keys) that call_content_model() can
2248 * dispatch to. MUST mirror the if/elseif chain below — the Content Model
2249 * picker derives its provider set from this list (plan ccb751), so a
2250 * provider added to the dispatch without updating this is never offered,
2251 * and one added here without dispatch support would break generation.
2252 * OpenRouter and the custom OpenAI-compatible provider are absent on
2253 * purpose: their pseudo-model ids would fall through to the OpenAI default
2254 * branch with the wrong key and endpoint.
2255 */
2256 public static function supported_content_providers() {
2257 return array('openai', 'claude', 'gemini', 'xai', 'deepseek');
2258 }
2259
2260 private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
2261 $options = get_option('mxchat_options', array());
2262 $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol';
2263
2264 // Determine provider from model name
2265 if ($this->is_claude_model($model)) {
2266 return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
2267 } elseif ($this->is_gemini_model($model)) {
2268 return $this->call_gemini($model, $options['gemini_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
2269 } elseif ($this->is_xai_model($model)) {
2270 return $this->call_openai_compatible($model, $options['xai_api_key'] ?? '', 'https://api.x.ai/v1/chat/completions', $system_prompt, $messages, $max_tokens);
2271 } elseif ($this->is_deepseek_model($model)) {
2272 return $this->call_openai_compatible($model, $options['deepseek_api_key'] ?? '', 'https://api.deepseek.com/chat/completions', $system_prompt, $messages, $max_tokens);
2273 } else {
2274 // Default: OpenAI
2275 return $this->call_openai_compatible($model, $options['api_key'] ?? '', 'https://api.openai.com/v1/chat/completions', $system_prompt, $messages, $max_tokens);
2276 }
2277 }
2278
2279 /**
2280 * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
2281 */
2282 /**
2283 * plan-mxchat-20260714-dcb71c: frozen pre-dcb71c 'content' reasoning ladder,
2284 * used only when the core model catalog is unavailable. Byte-identical to the
2285 * old inline if/elseif at the call site.
2286 */
2287 private function mxchat_cg_reasoning_effort_fallback($model) {
2288 if (strpos($model, 'gpt-5') !== 0 && strpos($model, 'gpt-6') !== 0) return null; // gpt-6 too (plan 45c22d)
2289 if ($model === 'gpt-5.2' || $model === 'gpt-5.1-chat-latest') return null;
2290 if ($model === 'gpt-5.1-2025-11-13') return 'low';
2291 if ($model === 'gpt-5.5') return 'low';
2292 if ($model === 'gpt-5.4') return 'low';
2293 if (in_array($model, array('gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) return 'low'; // plan 45c22d
2294 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
2295 return 'minimal';
2296 }
2297
2298 private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
2299 if (empty($api_key)) {
2300 return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
2301 }
2302
2303 $formatted = array();
2304 $formatted[] = array('role' => 'system', 'content' => $system_prompt);
2305 foreach ($messages as $msg) {
2306 $formatted[] = array(
2307 'role' => $msg['role'] ?? 'user',
2308 'content' => $msg['content'] ?? '',
2309 );
2310 }
2311
2312 // GPT-5.x models require max_completion_tokens and only support temperature=1.
2313 // Token key, temperature gate, and reasoning_effort are sourced from the
2314 // core model catalog (plan-dcb71c); frozen fallbacks preserve exact
2315 // pre-dcb71c behavior if the catalog class is unavailable.
2316 $is_gpt5 = strpos($model, 'gpt-5') === 0 || strpos($model, 'gpt-6') === 0; // gpt-6 too (plan 45c22d)
2317 $catalog = class_exists('MxChat_Model_Catalog');
2318 $token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param'))
2319 ? MxChat_Model_Catalog::openai_token_param($model)
2320 : ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens');
2321
2322 $body = array(
2323 'model' => $model,
2324 'messages' => $formatted,
2325 $token_key => $max_tokens,
2326 'stream' => false,
2327 );
2328
2329 $temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2330 ? MxChat_Model_Catalog::supports_temperature($model)
2331 : !$is_gpt5;
2332 if ($temp_ok) {
2333 $body['temperature'] = 0.7;
2334 }
2335
2336 $effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for'))
2337 ? MxChat_Model_Catalog::reasoning_effort_for($model, 'content')
2338 : $this->mxchat_cg_reasoning_effort_fallback($model);
2339 if ($effort !== null) {
2340 $body['reasoning_effort'] = $effort;
2341 }
2342
2343 // DeepSeek V4 defaults to thinking mode ON (temperature ignored and
2344 // reasoning eats the token budget); generation wants the legacy
2345 // deepseek-chat semantics = non-thinking. Endpoint-gated because this
2346 // helper is shared with OpenAI and xAI.
2347 if (strpos($endpoint, 'api.deepseek.com') !== false) {
2348 $body['thinking'] = array('type' => 'disabled');
2349 }
2350
2351 // Scale timeout with token count — large generation calls need more time
2352 $timeout = ($max_tokens > 8000) ? 300 : 120;
2353
2354 $response = wp_remote_post($endpoint, array(
2355 'headers' => array(
2356 'Authorization' => 'Bearer ' . $api_key,
2357 'Content-Type' => 'application/json',
2358 ),
2359 'body' => wp_json_encode($body),
2360 'timeout' => $timeout,
2361 ));
2362
2363 if (is_wp_error($response)) {
2364 return $response;
2365 }
2366
2367 $status_code = wp_remote_retrieve_response_code($response);
2368 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2369
2370 // plan-25b972 self-heal: supported reasoning_effort VALUES are per-model;
2371 // a 400 rejecting the value we sent (stale catalog entry / provider
2372 // drift) is deterministic — strip the param and retry ONCE.
2373 if ($status_code === 400
2374 && isset($body['reasoning_effort'])
2375 && isset($decoded['error']['message'])
2376 && is_string($decoded['error']['message'])
2377 && preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) {
2378 if (defined('WP_DEBUG') && WP_DEBUG) {
2379 error_log(sprintf(
2380 '[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).',
2381 $model, $body['reasoning_effort']
2382 ));
2383 }
2384 unset($body['reasoning_effort']);
2385 $response = wp_remote_post($endpoint, array(
2386 'headers' => array(
2387 'Authorization' => 'Bearer ' . $api_key,
2388 'Content-Type' => 'application/json',
2389 ),
2390 'body' => wp_json_encode($body),
2391 'timeout' => $timeout,
2392 ));
2393 if (is_wp_error($response)) {
2394 return $response;
2395 }
2396 $status_code = wp_remote_retrieve_response_code($response);
2397 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2398 }
2399
2400 if ($status_code !== 200) {
2401 $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
2402 return new WP_Error('api_error', $error_msg);
2403 }
2404
2405 if (isset($decoded['choices'][0]['message']['content'])) {
2406 return trim($decoded['choices'][0]['message']['content']);
2407 }
2408
2409 return new WP_Error('unexpected_response', __('Unexpected API response format.', 'mxchat'));
2410 }
2411
2412 /**
2413 * Call Claude (Anthropic) API
2414 */
2415 private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
2416 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
2417 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
2418 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
2419 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
2420 if (empty($api_key)) {
2421 return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
2422 }
2423
2424 $formatted = array();
2425 foreach ($messages as $msg) {
2426 $role = $msg['role'] ?? 'user';
2427 if (!in_array($role, array('user', 'assistant'), true)) {
2428 $role = 'user';
2429 }
2430 $formatted[] = array(
2431 'role' => $role,
2432 'content' => $msg['content'] ?? '',
2433 );
2434 }
2435
2436 $body = array(
2437 'model' => $model,
2438 'max_tokens' => $max_tokens,
2439 'temperature' => 0.7,
2440 'messages' => $formatted,
2441 // Prompt-cache breakpoint (plan 1ff43b): generation pipelines reuse
2442 // the same system prompt across consecutive requests within the
2443 // 5-minute cache window. Silently no-ops below the model minimum.
2444 'system' => trim((string) $system_prompt) === '' ? $system_prompt : array(
2445 array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')),
2446 ),
2447 );
2448
2449 // Anthropic removed temperature on Opus 4.7+ flagships (400 if sent) —
2450 // sourced from the core model catalog (plan-dcb71c); frozen list fallback.
2451 $omit_temp = (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2452 ? !MxChat_Model_Catalog::supports_temperature($model)
2453 : in_array($model, array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5'), true);
2454 if ($omit_temp) {
2455 unset($body['temperature']);
2456 }
2457
2458 $timeout = ($max_tokens > 8000) ? 300 : 120;
2459
2460 $response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
2461 'headers' => array(
2462 'Content-Type' => 'application/json',
2463 'x-api-key' => $api_key,
2464 'anthropic-version' => '2023-06-01',
2465 ),
2466 'body' => wp_json_encode($body),
2467 'timeout' => $timeout,
2468 ));
2469
2470 if (is_wp_error($response)) {
2471 return $response;
2472 }
2473
2474 $status_code = wp_remote_retrieve_response_code($response);
2475 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2476
2477 if ($status_code !== 200) {
2478 $error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code;
2479 return new WP_Error('claude_error', $error_msg);
2480 }
2481
2482 // claude-fable-5 prepends a thinking block to content — take the
2483 // first TEXT block, not content[0].
2484 if (isset($decoded['content']) && is_array($decoded['content'])) {
2485 foreach ($decoded['content'] as $block) {
2486 if (isset($block['type'], $block['text']) && $block['type'] === 'text') {
2487 return trim($block['text']);
2488 }
2489 }
2490 }
2491
2492 return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat'));
2493 }
2494
2495
2496 /**
2497 * Call Gemini API
2498 */
2499 private function call_gemini($model, $api_key, $system_prompt, $messages, $max_tokens) {
2500 if ($model === 'gemini-3-pro-preview') {
2501 $model = 'gemini-3.1-pro-preview';
2502 }
2503 if (empty($api_key)) {
2504 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
2505 }
2506
2507 $formatted = array();
2508
2509 // System instructions as first user message
2510 $formatted[] = array(
2511 'role' => 'user',
2512 'parts' => array(array('text' => "[System Instructions] " . $system_prompt)),
2513 );
2514 $formatted[] = array(
2515 'role' => 'model',
2516 'parts' => array(array('text' => "I understand and will follow these instructions.")),
2517 );
2518
2519 foreach ($messages as $msg) {
2520 $role = ($msg['role'] ?? 'user') === 'assistant' ? 'model' : 'user';
2521 $formatted[] = array(
2522 'role' => $role,
2523 'parts' => array(array('text' => $msg['content'] ?? '')),
2524 );
2525 }
2526
2527 $body = array(
2528 'contents' => $formatted,
2529 'generationConfig' => array(
2530 'temperature' => 0.7,
2531 'topP' => 0.95,
2532 'topK' => 40,
2533 'maxOutputTokens' => $max_tokens,
2534 ),
2535 );
2536
2537 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2538 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key={$api_key}";
2539
2540 $timeout = ($max_tokens > 8000) ? 300 : 120;
2541
2542 $response = wp_remote_post($url, array(
2543 'headers' => array('Content-Type' => 'application/json'),
2544 'body' => wp_json_encode($body),
2545 'timeout' => $timeout,
2546 ));
2547
2548 if (is_wp_error($response)) {
2549 return $response;
2550 }
2551
2552 $status_code = wp_remote_retrieve_response_code($response);
2553 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2554
2555 if ($status_code !== 200) {
2556 $error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code;
2557 return new WP_Error('gemini_error', $error_msg);
2558 }
2559
2560 if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) {
2561 return trim($decoded['candidates'][0]['content']['parts'][0]['text']);
2562 }
2563
2564 return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat'));
2565 }
2566
2567 // ─── Model Detection Helpers ────────────────────────────────────────
2568
2569 private function is_claude_model($model) {
2570 return strpos($model, 'claude') === 0;
2571 }
2572
2573 private function is_gemini_model($model) {
2574 return strpos($model, 'gemini') === 0;
2575 }
2576
2577 private function is_xai_model($model) {
2578 return strpos($model, 'grok') === 0;
2579 }
2580
2581 private function is_deepseek_model($model) {
2582 return strpos($model, 'deepseek') === 0;
2583 }
2584
2585 // ─── Content Settings Save ────────────────────────────────────────
2586
2587 /**
2588 * Dedicated handler for saving content generator settings.
2589 * Bypasses the main settings handler entirely.
2590 */
2591 public function handle_save_content_setting() {
2592 check_ajax_referer('mxchat_content_nonce', 'nonce');
2593
2594 if (!current_user_can('manage_options')) {
2595 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2596 }
2597
2598 $field = sanitize_text_field($_POST['field'] ?? '');
2599 $value = sanitize_text_field($_POST['value'] ?? '');
2600
2601 $allowed_fields = array('content_model', 'content_image_model', 'content_image_quality', 'content_image_count', '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');
2602 if (!in_array($field, $allowed_fields, true)) {
2603 wp_send_json_error(array('message' => __('Invalid field.', 'mxchat')));
2604 }
2605
2606 // Toggle fields
2607 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)) {
2608 $value = ($value === 'on') ? 'on' : 'off';
2609 }
2610
2611 // Enum field: image quality
2612 if ($field === 'content_image_quality') {
2613 $value = in_array($value, array('auto', 'low', 'medium', 'high'), true) ? $value : 'auto';
2614 }
2615
2616 // Integer field: images per article (clamp 1–5)
2617 if ($field === 'content_image_count') {
2618 $value = (string) max(1, min(5, (int) $value));
2619 }
2620
2621 $options = get_option('mxchat_options', array());
2622 $options[$field] = $value;
2623 update_option('mxchat_options', $options);
2624
2625 wp_send_json_success(array('message' => __('Setting saved.', 'mxchat')));
2626 }
2627
2628 // ─── Progress Tracking ─────────────────────────────────────────────
2629
2630 /**
2631 * Update generation progress.
2632 * Uses wp_options directly (not transients) to avoid object cache
2633 * stale-read issues across the background worker and polling processes.
2634 */
2635 private function update_progress($key, $step, $message, $percent, $result = null) {
2636 global $wpdb;
2637
2638 $data = array(
2639 'step' => $step,
2640 'message' => $message,
2641 'percent' => $percent,
2642 'updated' => time(),
2643 );
2644 if ($result !== null) {
2645 $data['result'] = $result;
2646 }
2647
2648 $option_name = '_mxchat_progress_' . $key;
2649 $serialized = maybe_serialize($data);
2650
2651 // Direct DB write — bypasses object cache entirely
2652 $exists = $wpdb->get_var($wpdb->prepare(
2653 "SELECT COUNT(*) FROM $wpdb->options WHERE option_name = %s",
2654 $option_name
2655 ));
2656
2657 if ($exists) {
2658 $wpdb->update(
2659 $wpdb->options,
2660 array('option_value' => $serialized),
2661 array('option_name' => $option_name)
2662 );
2663 } else {
2664 $wpdb->insert(
2665 $wpdb->options,
2666 array(
2667 'option_name' => $option_name,
2668 'option_value' => $serialized,
2669 'autoload' => 'no',
2670 )
2671 );
2672 }
2673
2674 // Also bust the object cache in case anything reads via get_option()
2675 wp_cache_delete($option_name, 'options');
2676 }
2677
2678 /**
2679 * Read generation progress.
2680 * Direct DB read — bypasses object cache for guaranteed freshness.
2681 */
2682 private function get_progress($key) {
2683 global $wpdb;
2684
2685 $option_name = '_mxchat_progress_' . $key;
2686
2687 $value = $wpdb->get_var($wpdb->prepare(
2688 "SELECT option_value FROM $wpdb->options WHERE option_name = %s",
2689 $option_name
2690 ));
2691
2692 if ($value === null) {
2693 return false;
2694 }
2695
2696 return maybe_unserialize($value);
2697 }
2698
2699 /**
2700 * Delete generation progress (cleanup).
2701 */
2702 private function delete_progress($key) {
2703 global $wpdb;
2704
2705 $option_name = '_mxchat_progress_' . $key;
2706 $wpdb->delete($wpdb->options, array('option_name' => $option_name));
2707 wp_cache_delete($option_name, 'options');
2708 }
2709
2710
2711 // ─── Content History ──────────────────────────────────────────────
2712
2713 /**
2714 * Return paginated list of AI-generated posts for the History tab.
2715 */
2716 public function handle_content_history() {
2717 check_ajax_referer('mxchat_content_nonce', 'nonce');
2718
2719 if (!current_user_can('manage_options')) {
2720 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2721 }
2722
2723 $page = max(1, intval($_POST['page'] ?? 1));
2724 $per_page = 10;
2725
2726 $query = new WP_Query(array(
2727 'post_type' => array('post', 'page'),
2728 'post_status' => array('publish', 'draft', 'future', 'pending', 'private'),
2729 'meta_key' => '_mxchat_generated',
2730 'meta_value' => '1',
2731 'posts_per_page' => $per_page,
2732 'paged' => $page,
2733 'orderby' => 'date',
2734 'order' => 'DESC',
2735 ));
2736
2737 $items = array();
2738 foreach ($query->posts as $post) {
2739 $thumb = get_the_post_thumbnail_url($post->ID, 'thumbnail');
2740 $items[] = array(
2741 'post_id' => $post->ID,
2742 'title' => $post->post_title,
2743 'status' => $post->post_status,
2744 'post_type' => $post->post_type,
2745 'date' => get_the_date('M j, Y', $post),
2746 'thumbnail' => $thumb ? $thumb : '',
2747 'permalink' => get_permalink($post->ID),
2748 );
2749 }
2750
2751 wp_send_json_success(array(
2752 'items' => $items,
2753 'total' => (int) $query->found_posts,
2754 'total_pages' => (int) $query->max_num_pages,
2755 'current_page' => $page,
2756 ));
2757 }
2758
2759 /**
2760 * Move an AI-generated post to the trash.
2761 */
2762 public function handle_delete_content() {
2763 check_ajax_referer('mxchat_content_nonce', 'nonce');
2764
2765 if (!current_user_can('manage_options')) {
2766 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2767 }
2768
2769 $post_id = intval($_POST['post_id'] ?? 0);
2770 if (!$post_id) {
2771 wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
2772 }
2773
2774 // Only allow deleting MxChat-generated content
2775 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
2776 wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
2777 }
2778
2779 $result = wp_trash_post($post_id);
2780 if (!$result) {
2781 wp_send_json_error(array('message' => __('Failed to delete post.', 'mxchat')));
2782 }
2783
2784 wp_send_json_success(array('post_id' => $post_id));
2785 }
2786
2787 /**
2788 * Update the status of an AI-generated post (draft, publish, future).
2789 */
2790 public function handle_update_post_status() {
2791 check_ajax_referer('mxchat_content_nonce', 'nonce');
2792
2793 if (!current_user_can('manage_options')) {
2794 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2795 }
2796
2797 $post_id = intval($_POST['post_id'] ?? 0);
2798 $new_status = sanitize_text_field($_POST['new_status'] ?? '');
2799 $schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
2800
2801 if (!$post_id) {
2802 wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
2803 }
2804
2805 // Only allow updating MxChat-generated content
2806 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
2807 wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
2808 }
2809
2810 if (!in_array($new_status, array('draft', 'publish', 'future'), true)) {
2811 wp_send_json_error(array('message' => __('Invalid status.', 'mxchat')));
2812 }
2813
2814 $post_args = array('ID' => $post_id, 'post_status' => $new_status);
2815
2816 // Scheduled: require future date
2817 if ($new_status === 'future') {
2818 if (empty($schedule_date)) {
2819 wp_send_json_error(array('message' => __('A schedule date is required.', 'mxchat')));
2820 }
2821 $post_args['post_date'] = $schedule_date;
2822 $post_args['post_date_gmt'] = get_gmt_from_date($schedule_date);
2823 $post_args['edit_date'] = true;
2824 }
2825
2826 // Transitioning FROM future to draft/publish: reset post_date to now
2827 if ($new_status !== 'future') {
2828 $current = get_post($post_id);
2829 if ($current && $current->post_status === 'future') {
2830 $post_args['post_date'] = current_time('mysql');
2831 $post_args['post_date_gmt'] = current_time('mysql', true);
2832 $post_args['edit_date'] = true;
2833 }
2834 }
2835
2836 $result = wp_update_post($post_args, true);
2837 if (is_wp_error($result)) {
2838 wp_send_json_error(array('message' => $result->get_error_message()));
2839 }
2840
2841 // Re-read to get confirmed status (WP may auto-publish if schedule date is past)
2842 $updated = get_post($post_id);
2843
2844 wp_send_json_success(array(
2845 'post_id' => $post_id,
2846 'status' => $updated->post_status,
2847 ));
2848 }
2849
2850 /**
2851 * Load an existing AI-generated post into the editor state.
2852 * Returns the same data shape as the generation success response.
2853 */
2854 public function handle_load_post_for_edit() {
2855 check_ajax_referer('mxchat_content_nonce', 'nonce');
2856
2857 if (!current_user_can('manage_options')) {
2858 wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
2859 }
2860
2861 $post_id = intval($_POST['post_id'] ?? 0);
2862 if (!$post_id) {
2863 wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
2864 }
2865
2866 $post = get_post($post_id);
2867 if (!$post) {
2868 wp_send_json_error(array('message' => __('Post not found.', 'mxchat')));
2869 }
2870
2871 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
2872 wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
2873 }
2874
2875 $preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
2876 $edit_url = admin_url('post.php?post=' . $post_id . '&action=edit');
2877 $permalink = get_permalink($post_id);
2878
2879 // Discover images — prefer stored IDs, fall back to post_parent query
2880 $images = $this->discover_post_images($post_id);
2881
2882 wp_send_json_success(array(
2883 'post_id' => $post_id,
2884 'preview_url' => $preview_url,
2885 'edit_url' => $edit_url,
2886 'permalink' => $permalink,
2887 'title' => $post->post_title,
2888 'status' => $post->post_status,
2889 'images' => $images,
2890 'meta' => array(
2891 'description' => $this->get_meta_description($post_id),
2892 'keyword' => $this->get_focus_keyword($post_id),
2893 'excerpt' => $post->post_excerpt,
2894 ),
2895 ));
2896 }
2897
2898 /**
2899 * Discover images associated with a generated post.
2900 * Uses stored attachment IDs (most reliable), falls back to post_parent query.
2901 */
2902 private function discover_post_images($post_id) {
2903 $images = array();
2904 $stored_ids = get_post_meta($post_id, '_mxchat_image_ids', true);
2905
2906 if (!empty($stored_ids) && is_array($stored_ids)) {
2907 // Primary: use stored attachment IDs
2908 foreach ($stored_ids as $att_id) {
2909 $att_url = wp_get_attachment_url($att_id);
2910 if ($att_url) {
2911 $thumb_url = wp_get_attachment_image_url($att_id, 'medium');
2912 $images[] = array(
2913 'url' => $att_url,
2914 'thumbnail' => $thumb_url ?: $att_url,
2915 'attachment_id' => $att_id,
2916 );
2917 }
2918 }
2919 } else {
2920 // Fallback: query by post_parent + meta key
2921 $attachments = get_posts(array(
2922 'post_type' => 'attachment',
2923 'post_mime_type' => 'image',
2924 'posts_per_page' => -1,
2925 'post_parent' => $post_id,
2926 'meta_key' => '_mxchat_image_prompt',
2927 'meta_compare' => 'EXISTS',
2928 'orderby' => 'date',
2929 'order' => 'ASC',
2930 ));
2931 foreach ($attachments as $att) {
2932 $att_url = wp_get_attachment_url($att->ID);
2933 if ($att_url) {
2934 $thumb_url = wp_get_attachment_image_url($att->ID, 'medium');
2935 $images[] = array(
2936 'url' => $att_url,
2937 'thumbnail' => $thumb_url ?: $att_url,
2938 'attachment_id' => $att->ID,
2939 );
2940 }
2941 }
2942 }
2943
2944 // Fallback: include featured image if nothing else found
2945 $featured_id = get_post_thumbnail_id($post_id);
2946 if (empty($images) && $featured_id) {
2947 $feat_url = wp_get_attachment_url($featured_id);
2948 $feat_thumb = wp_get_attachment_image_url($featured_id, 'medium');
2949 if ($feat_url) {
2950 $images[] = array(
2951 'url' => $feat_url,
2952 'thumbnail' => $feat_thumb ?: $feat_url,
2953 'attachment_id' => $featured_id,
2954 );
2955 }
2956 }
2957
2958 return $images;
2959 }
2960
2961 /**
2962 * Get the focus keyword (first keyword from comma-separated list).
2963 */
2964 private function get_seo_plugin() {
2965 if (class_exists('RankMath')) return 'rankmath';
2966 if (defined('WPSEO_VERSION')) return 'yoast';
2967 if (function_exists('aioseo')) return 'aioseo';
2968 return 'none';
2969 }
2970
2971 private function get_meta_description($post_id) {
2972 $plugin = $this->get_seo_plugin();
2973 $keys = array(
2974 'rankmath' => 'rank_math_description',
2975 'yoast' => '_yoast_wpseo_metadesc',
2976 'aioseo' => '_aioseo_description',
2977 );
2978 if (isset($keys[$plugin])) {
2979 $val = get_post_meta($post_id, $keys[$plugin], true);
2980 if (!empty($val)) return $val;
2981 }
2982 return get_post_meta($post_id, '_mxchat_meta_description', true);
2983 }
2984
2985 private function set_meta_description($post_id, $value) {
2986 $value = sanitize_text_field($value);
2987 $plugin = $this->get_seo_plugin();
2988 $keys = array(
2989 'rankmath' => 'rank_math_description',
2990 'yoast' => '_yoast_wpseo_metadesc',
2991 'aioseo' => '_aioseo_description',
2992 );
2993 if (isset($keys[$plugin])) {
2994 update_post_meta($post_id, $keys[$plugin], $value);
2995 } else {
2996 update_post_meta($post_id, '_mxchat_meta_description', $value);
2997 }
2998 }
2999
3000 private function get_focus_keyword($post_id) {
3001 $plugin = $this->get_seo_plugin();
3002 $keys = array(
3003 'rankmath' => 'rank_math_focus_keyword',
3004 'yoast' => '_yoast_wpseo_focuskw',
3005 );
3006 if (isset($keys[$plugin])) {
3007 $val = get_post_meta($post_id, $keys[$plugin], true);
3008 if (!empty($val)) {
3009 $parts = explode(',', $val);
3010 return trim($parts[0]);
3011 }
3012 }
3013 $keywords = get_post_meta($post_id, '_mxchat_keywords', true);
3014 if (!empty($keywords)) {
3015 $parts = explode(',', $keywords);
3016 return trim($parts[0]);
3017 }
3018 return '';
3019 }
3020
3021 private function set_focus_keyword($post_id, $value) {
3022 $value = sanitize_text_field($value);
3023 $plugin = $this->get_seo_plugin();
3024 $keys = array(
3025 'rankmath' => 'rank_math_focus_keyword',
3026 'yoast' => '_yoast_wpseo_focuskw',
3027 );
3028 if (isset($keys[$plugin])) {
3029 update_post_meta($post_id, $keys[$plugin], $value);
3030 } else {
3031 update_post_meta($post_id, '_mxchat_keywords', $value);
3032 }
3033 }
3034
3035 // ─── SEO Analysis ──────────────────────────────────────────────
3036
3037 /**
3038 * Analyze a generated post for SEO quality.
3039 * Returns a 0-100 score with individual check results.
3040 */
3041 public function handle_seo_analyze() {
3042 check_ajax_referer('mxchat_content_nonce', 'nonce');
3043
3044 if (!current_user_can('edit_posts')) {
3045 wp_send_json_error('Unauthorized');
3046 }
3047
3048 $post_id = intval($_POST['post_id'] ?? 0);
3049 if (!$post_id || !get_post($post_id)) {
3050 wp_send_json_error('Post not found');
3051 }
3052
3053 $result = $this->seo_score_post($post_id);
3054 wp_send_json_success($result);
3055 }
3056
3057 public function handle_seo_analyze_batch() {
3058 check_ajax_referer('mxchat_content_nonce', 'nonce');
3059
3060 if (!current_user_can('edit_posts')) {
3061 wp_send_json_error('Unauthorized');
3062 }
3063
3064 $post_ids = array_map('intval', (array) ($_POST['post_ids'] ?? array()));
3065 $post_ids = array_filter($post_ids);
3066 if (empty($post_ids) || count($post_ids) > 50) {
3067 wp_send_json_error('Invalid post IDs (1-50 allowed)');
3068 }
3069
3070 $results = array();
3071 foreach ($post_ids as $pid) {
3072 if (get_post($pid)) {
3073 $results[$pid] = $this->seo_score_post($pid);
3074 }
3075 }
3076
3077 wp_send_json_success(array('results' => $results));
3078 }
3079
3080 private function seo_score_post($post_id) {
3081 $post = get_post($post_id);
3082
3083 $title = $post->post_title;
3084 $content = $post->post_content;
3085 $slug = $post->post_name;
3086 $meta_desc = $this->get_meta_description($post_id);
3087 $focus_kw = $this->get_focus_keyword($post_id);
3088 $text = wp_strip_all_tags($content);
3089 $word_count = str_word_count($text);
3090
3091 // Parse images
3092 preg_match_all('/<img[^>]*>/i', $content, $img_matches);
3093 $total_images = count($img_matches[0]);
3094 $images_with_alt = 0;
3095 foreach ($img_matches[0] as $img) {
3096 if (preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img, $alt_m) && trim($alt_m[1]) !== '') {
3097 $images_with_alt++;
3098 }
3099 }
3100
3101 // Parse links
3102 preg_match_all('/<a[^>]+href\s*=\s*["\']([^"\']+)["\']/i', $content, $link_matches);
3103 $site_url = home_url();
3104 $internal_links = 0;
3105 if (!empty($link_matches[1])) {
3106 foreach ($link_matches[1] as $href) {
3107 if (strpos($href, $site_url) === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) {
3108 $internal_links++;
3109 }
3110 }
3111 }
3112
3113 // Parse headings
3114 preg_match_all('/<h([1-6])[^>]*>/i', $content, $h_matches);
3115 $heading_count = count($h_matches[0]);
3116 $has_subheadings = false;
3117 foreach ($h_matches[1] as $lvl) {
3118 if ($lvl >= 2) { $has_subheadings = true; break; }
3119 }
3120
3121 $checks = array();
3122 $score = 100;
3123
3124 // 1. Title length
3125 $tl = mb_strlen($title);
3126 if ($tl === 0) $checks['title_length'] = array('status' => 'fail', 'label' => 'Page Title', 'detail' => 'Missing', 'penalty' => 20);
3127 elseif ($tl < 30) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — too short (aim for 50–60)', 'penalty' => 5);
3128 elseif ($tl > 70) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — may truncate in search', 'penalty' => 3);
3129 else $checks['title_length'] = array('status' => 'pass', 'label' => 'Page Title', 'detail' => $tl . ' chars — good length', 'penalty' => 0);
3130
3131 // 2. Meta description
3132 $ml = mb_strlen($meta_desc);
3133 if ($ml === 0) $checks['meta_desc'] = array('status' => 'fail', 'label' => 'Meta Description', 'detail' => 'Missing — engines will auto-generate one', 'penalty' => 15);
3134 elseif ($ml < 120) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — could be longer (150–160)', 'penalty' => 3);
3135 elseif ($ml > 160) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — may truncate (150–160)', 'penalty' => 3);
3136 else $checks['meta_desc'] = array('status' => 'pass', 'label' => 'Meta Description', 'detail' => $ml . ' chars — good length', 'penalty' => 0);
3137
3138 // 3. Focus keyword placement
3139 if (empty($focus_kw)) {
3140 $checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'Not set — helps guide optimization', 'penalty' => 5);
3141 } else {
3142 $places = array();
3143 if (mb_stripos($title, $focus_kw) !== false) $places[] = 'title';
3144 if (mb_stripos($meta_desc, $focus_kw) !== false) $places[] = 'meta';
3145 if (mb_stripos($text, $focus_kw) !== false) $places[] = 'content';
3146 if (stripos($slug, str_replace(' ', '-', strtolower($focus_kw))) !== false) $places[] = 'slug';
3147
3148 if (count($places) >= 3) $checks['focus_kw'] = array('status' => 'pass', 'label' => 'Focus Keyword', 'detail' => 'Found in ' . implode(', ', $places), 'penalty' => 0);
3149 elseif (count($places) >= 1) {
3150 $miss = array_diff(array('title', 'meta', 'content', 'slug'), $places);
3151 $checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'In ' . implode(', ', $places) . ' — missing from ' . implode(', ', array_slice($miss, 0, 2)), 'penalty' => 5);
3152 } else $checks['focus_kw'] = array('status' => 'fail', 'label' => 'Focus Keyword', 'detail' => '"' . esc_html($focus_kw) . '" not found in content', 'penalty' => 10);
3153 }
3154
3155 // 4. Content depth
3156 if ($word_count < 300) $checks['content_depth'] = array('status' => 'fail', 'label' => 'Content Depth', 'detail' => $word_count . ' words — thin (aim for 800+)', 'penalty' => 12);
3157 elseif ($word_count < 600) $checks['content_depth'] = array('status' => 'warn', 'label' => 'Content Depth', 'detail' => $word_count . ' words — light (800+ ideal)', 'penalty' => 5);
3158 else $checks['content_depth'] = array('status' => 'pass', 'label' => 'Content Depth', 'detail' => number_format($word_count) . ' words', 'penalty' => 0);
3159
3160 // 5. Heading structure
3161 if ($heading_count === 0) $checks['headings'] = array('status' => 'fail', 'label' => 'Heading Structure', 'detail' => 'No headings — add H2s to organize content', 'penalty' => 10);
3162 elseif (!$has_subheadings) $checks['headings'] = array('status' => 'warn', 'label' => 'Heading Structure', 'detail' => 'Missing subheadings (H2/H3)', 'penalty' => 5);
3163 else $checks['headings'] = array('status' => 'pass', 'label' => 'Heading Structure', 'detail' => $heading_count . ' headings — well structured', 'penalty' => 0);
3164
3165 // 6. Image ALT text
3166 if ($total_images === 0) {
3167 $checks['img_alt'] = array('status' => 'warn', 'label' => 'Image ALT Text', 'detail' => 'No images found', 'penalty' => 2);
3168 } else {
3169 $missing = $total_images - $images_with_alt;
3170 $checks['img_alt'] = $missing === 0
3171 ? array('status' => 'pass', 'label' => 'Image ALT Text', 'detail' => 'All ' . $total_images . ' images have ALT text', 'penalty' => 0)
3172 : array('status' => 'fail', 'label' => 'Image ALT Text', 'detail' => $missing . '/' . $total_images . ' missing ALT text', 'penalty' => min($missing * 3, 12));
3173 }
3174
3175 // 7. Internal links
3176 if ($internal_links === 0 && $word_count >= 300)
3177 $checks['internal_links'] = array('status' => 'fail', 'label' => 'Internal Links', 'detail' => 'None — link to related content', 'penalty' => 8);
3178 else
3179 $checks['internal_links'] = array('status' => 'pass', 'label' => 'Internal Links', 'detail' => $internal_links ? $internal_links . ' found' : 'Short content — optional', 'penalty' => 0);
3180
3181 // 8. Slug quality
3182 $sw = count(explode('-', $slug));
3183 if (strlen($slug) > 75) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => 'Too long — shorten to 3–5 words', 'penalty' => 3);
3184 elseif ($sw > 8) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => $sw . ' words — keep to 3–5', 'penalty' => 2);
3185 else $checks['slug'] = array('status' => 'pass', 'label' => 'URL Slug', 'detail' => '/' . esc_html($slug), 'penalty' => 0);
3186
3187 // 9. Readability (Flesch-Kincaid)
3188 $sents = max(count(preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY)), 1);
3189 $syls = $this->seo_count_syllables($text);
3190 $fk = max(0, min(100, round(206.835 - 1.015 * ($word_count / $sents) - 84.6 * ($syls / max($word_count, 1)))));
3191 if ($fk >= 60) $checks['readability'] = array('status' => 'pass', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — easy to read', 'penalty' => 0);
3192 elseif ($fk >= 40) $checks['readability'] = array('status' => 'warn', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — somewhat complex', 'penalty' => 3);
3193 else $checks['readability'] = array('status' => 'fail', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — hard to read, simplify', 'penalty' => 7);
3194
3195 // 10. Featured image
3196 $checks['featured_img'] = has_post_thumbnail($post_id)
3197 ? array('status' => 'pass', 'label' => 'Featured Image', 'detail' => 'Set', 'penalty' => 0)
3198 : array('status' => 'warn', 'label' => 'Featured Image', 'detail' => 'Missing — important for social sharing', 'penalty' => 4);
3199
3200 // Calculate score
3201 foreach ($checks as $c) { $score -= $c['penalty']; }
3202 $score = max(0, min(100, $score));
3203
3204 $pass = $warn = $fail = 0;
3205 foreach ($checks as $c) {
3206 if ($c['status'] === 'pass') $pass++;
3207 elseif ($c['status'] === 'warn') $warn++;
3208 else $fail++;
3209 }
3210
3211 // Cache results to post meta for the SEO dashboard list view
3212 update_post_meta($post_id, '_mxchat_seo_score', $score);
3213 update_post_meta($post_id, '_mxchat_seo_checks', $checks);
3214 update_post_meta($post_id, '_mxchat_seo_analyzed', time());
3215
3216 return array(
3217 'score' => $score,
3218 'checks' => $checks,
3219 'summary' => array('pass' => $pass, 'warn' => $warn, 'fail' => $fail),
3220 );
3221 }
3222
3223 /**
3224 * AI-powered SEO suggestion for a specific field.
3225 */
3226 public function handle_seo_suggest() {
3227 check_ajax_referer('mxchat_content_nonce', 'nonce');
3228 if (!current_user_can('edit_posts')) { wp_send_json_error('Unauthorized'); }
3229
3230 $post_id = intval($_POST['post_id'] ?? 0);
3231 $field = sanitize_text_field($_POST['field'] ?? '');
3232 if (!$post_id || !$field || !($post = get_post($post_id))) {
3233 wp_send_json_error('Missing parameters');
3234 }
3235
3236 $title = $post->post_title;
3237 $content = wp_strip_all_tags($post->post_content);
3238 $focus_kw = $this->get_focus_keyword($post_id);
3239
3240 // Sample content to manage token usage
3241 $sample = mb_strlen($content) > 1200
3242 ? mb_substr($content, 0, 800) . "\n...\n" . mb_substr($content, -400)
3243 : $content;
3244
3245 $kw = !empty($focus_kw) ? ' Incorporate the focus keyword "' . $focus_kw . '" naturally.' : '';
3246
3247 $prompts = array(
3248 '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,
3249 '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,
3250 'slug' => 'Generate an SEO-friendly URL slug. 3-5 lowercase words with hyphens, no stop words. Return ONLY the slug.' . $kw . "\n\nTitle: " . $title,
3251 'excerpt' => 'Write a concise excerpt in 1-2 sentences, under 200 characters. Return ONLY the text.' . $kw . "\n\nTitle: " . $title . "\n\nContent:\n" . $sample,
3252 'readability' => true, // Handled by Advanced Content Editor add-on
3253 'internal_links' => true, // Handled by Advanced Content Editor add-on
3254 'img_alt' => true, // Handled by Advanced Content Editor add-on
3255 'featured_img' => true, // Handled by Advanced Content Editor add-on
3256 );
3257
3258 if (!isset($prompts[$field])) { wp_send_json_error('Invalid field'); }
3259
3260 // These fields are handled by the Advanced Content Editor add-on
3261 $addon_fields = array('readability', 'internal_links', 'img_alt', 'featured_img');
3262 if (in_array($field, $addon_fields, true)) {
3263 $feature_key = 'seo_' . $field;
3264 $has_addon = apply_filters('mxchat_content_pro_feature', false, $feature_key);
3265 if (!$has_addon) {
3266 wp_send_json_error('This feature requires the Advanced Content Editor add-on.');
3267 return;
3268 }
3269 // Delegate to add-on via action hook
3270 do_action('mxchat_seo_optimize_' . $field, $post_id, $post, $focus_kw);
3271 return;
3272 }
3273
3274 $response = $this->call_content_model(
3275 'You are an expert SEO copywriter. Return only what is asked for. No quotes, no explanations, no prefixes.',
3276 array(array('role' => 'user', 'content' => $prompts[$field])),
3277 256
3278 );
3279
3280 if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); }
3281
3282 $suggestion = trim($response);
3283
3284 // Save suggestion
3285 if ($field === 'meta_description') {
3286 $this->set_meta_description($post_id, $suggestion);
3287 } elseif ($field === 'seo_title') {
3288 wp_update_post(array('ID' => $post_id, 'post_title' => sanitize_text_field($suggestion)));
3289 } elseif ($field === 'slug') {
3290 wp_update_post(array('ID' => $post_id, 'post_name' => sanitize_title($suggestion)));
3291 } elseif ($field === 'excerpt') {
3292 wp_update_post(array('ID' => $post_id, 'post_excerpt' => sanitize_text_field($suggestion)));
3293 }
3294
3295 wp_send_json_success(array('field' => $field, 'suggestion' => $suggestion));
3296 }
3297
3298 /**
3299 * List published posts/pages with cached SEO scores for the dashboard.
3300 */
3301 public function handle_seo_list_posts() {
3302 check_ajax_referer('mxchat_content_nonce', 'nonce');
3303
3304 $page = max(1, intval($_POST['page'] ?? 1));
3305 $per_page = 50;
3306 $post_type = sanitize_text_field($_POST['post_type'] ?? 'any');
3307 $filter = sanitize_text_field($_POST['filter'] ?? 'all');
3308 $search = sanitize_text_field($_POST['search'] ?? '');
3309 $sort_by = sanitize_text_field($_POST['sort_by'] ?? 'date');
3310 $sort_order = strtoupper(sanitize_text_field($_POST['sort_order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
3311
3312 // Map sort_by to WP_Query orderby
3313 $orderby = 'date';
3314 $sort_meta_key = '';
3315 switch ($sort_by) {
3316 case 'title':
3317 $orderby = 'title';
3318 break;
3319 case 'score':
3320 $orderby = 'meta_value_num';
3321 $sort_meta_key = '_mxchat_seo_score';
3322 break;
3323 case 'clicks':
3324 $orderby = 'meta_value_num';
3325 $sort_meta_key = '_mxchat_gsc_clicks';
3326 break;
3327 case 'impressions':
3328 $orderby = 'meta_value_num';
3329 $sort_meta_key = '_mxchat_gsc_impressions';
3330 break;
3331 default:
3332 $orderby = 'date';
3333 break;
3334 }
3335
3336 $args = array(
3337 'post_status' => 'publish',
3338 'posts_per_page' => -1,
3339 'post_type' => $post_type === 'any' ? array_values(array_diff(get_post_types(array('public' => true), 'names'), array('attachment'))) : $post_type,
3340 'orderby' => $orderby,
3341 'order' => $sort_order,
3342 'fields' => 'ids',
3343 );
3344
3345 if (!empty($search)) {
3346 $args['s'] = $search;
3347 }
3348
3349 // Meta query for score-based filters
3350 if ($filter === 'issues') {
3351 $args['meta_query'] = array(
3352 array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '<', 'type' => 'NUMERIC'),
3353 );
3354 } elseif ($filter === 'good') {
3355 $args['meta_query'] = array(
3356 array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '>=', 'type' => 'NUMERIC'),
3357 );
3358 } elseif ($filter === 'unscored') {
3359 $args['meta_query'] = array(
3360 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3361 );
3362 }
3363
3364 // When sorting by a meta field, posts WITHOUT the key must stay in the
3365 // list — GSC meta is no longer zero-filled for no-data posts (plan
3366 // 282c80), and score meta never existed for unanalyzed posts. A single
3367 // OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an
3368 // OR relation WP_Meta_Query leaves the orderby clause's join alias
3369 // unconstrained by meta_key, so ORDER BY reads arbitrary meta rows).
3370 // Instead: two id-only queries — posts WITH the key sorted by its
3371 // value, then posts WITHOUT it appended (newest first). Missing-data
3372 // rows deliberately sort last in BOTH directions.
3373 if ($sort_meta_key) {
3374 $with_args = $args;
3375 $with_args['meta_key'] = $sort_meta_key;
3376 $with_args['orderby'] = 'meta_value_num';
3377 $q_with = new \WP_Query($with_args);
3378
3379 $without_args = $args;
3380 $missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS');
3381 if (!empty($without_args['meta_query'])) {
3382 $without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause);
3383 } else {
3384 $without_args['meta_query'] = array($missing_clause);
3385 }
3386 $without_args['orderby'] = 'date';
3387 $without_args['order'] = 'DESC';
3388 $q_without = new \WP_Query($without_args);
3389
3390 $all_ids = array_merge($q_with->posts, $q_without->posts);
3391 } else {
3392 $query = new \WP_Query($args);
3393 $all_ids = $query->posts;
3394 }
3395 $total = count($all_ids);
3396 $pages = max(1, ceil($total / $per_page));
3397 $page = min($page, $pages);
3398 $offset = ($page - 1) * $per_page;
3399 $page_ids = array_slice($all_ids, $offset, $per_page);
3400
3401 $posts = array();
3402 foreach ($page_ids as $pid) {
3403 $p = get_post($pid);
3404 $score = get_post_meta($pid, '_mxchat_seo_score', true);
3405
3406 $gsc_clicks = get_post_meta($pid, '_mxchat_gsc_clicks', true);
3407 $gsc_impr = get_post_meta($pid, '_mxchat_gsc_impressions', true);
3408
3409 $posts[] = array(
3410 'id' => $pid,
3411 'title' => $p->post_title,
3412 'type' => $p->post_type,
3413 'date' => get_the_date('M j, Y', $pid),
3414 'edit_url' => get_edit_post_link($pid, 'raw'),
3415 'permalink' => get_permalink($pid),
3416 'score' => $score !== '' ? intval($score) : null,
3417 'analyzed' => (bool) get_post_meta($pid, '_mxchat_seo_analyzed', true),
3418 'clicks' => $gsc_clicks !== '' ? intval($gsc_clicks) : null,
3419 'impressions' => $gsc_impr !== '' ? intval($gsc_impr) : null,
3420 );
3421 }
3422
3423 // Count unscored for the Scan button
3424 $unscored_q = new \WP_Query(array(
3425 'post_status' => 'publish',
3426 'posts_per_page' => -1,
3427 'post_type' => array('post', 'page'),
3428 'fields' => 'ids',
3429 'meta_query' => array(
3430 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3431 ),
3432 ));
3433 $unscored_count = count($unscored_q->posts);
3434
3435 wp_send_json_success(array(
3436 'posts' => $posts,
3437 'page' => $page,
3438 'pages' => $pages,
3439 'total' => $total,
3440 'unscored_count' => $unscored_count,
3441 ));
3442 }
3443
3444 /**
3445 * Count syllables in text (Flesch-Kincaid helper).
3446 */
3447 private function seo_count_syllables($text) {
3448 $words = preg_split('/\s+/', strtolower($text), -1, PREG_SPLIT_NO_EMPTY);
3449 $total = 0;
3450 foreach ($words as $w) {
3451 $w = preg_replace('/[^a-z]/', '', $w);
3452 if (strlen($w) <= 3) { $total++; continue; }
3453 $w = preg_replace('/(?:[^laeiouy]es|ed|[^laeiouy]e)$/', '', $w);
3454 preg_match_all('/[aeiouy]{1,2}/', $w, $m);
3455 $total += max(1, count($m[0]));
3456 }
3457 return $total;
3458 }
3459 }