options = get_option('mxchat_options', array());
// AJAX hooks (admin only, but wp_ajax_ prefix ensures that)
add_action('wp_ajax_mxchat_generate_content', array($this, 'handle_generate_content'));
add_action('wp_ajax_mxchat_content_edit', array($this, 'handle_content_edit'));
add_action('wp_ajax_mxchat_content_progress', array($this, 'handle_content_progress'));
add_action('wp_ajax_mxchat_save_content_setting', array($this, 'handle_save_content_setting'));
add_action('wp_ajax_mxchat_content_history', array($this, 'handle_content_history'));
add_action('wp_ajax_mxchat_load_post_for_edit', array($this, 'handle_load_post_for_edit'));
add_action('wp_ajax_mxchat_delete_content', array($this, 'handle_delete_content'));
add_action('wp_ajax_mxchat_update_post_status', array($this, 'handle_update_post_status'));
add_action('wp_ajax_mxchat_seo_analyze', array($this, 'handle_seo_analyze'));
add_action('wp_ajax_mxchat_seo_analyze_batch', array($this, 'handle_seo_analyze_batch'));
add_action('wp_ajax_mxchat_seo_suggest', array($this, 'handle_seo_suggest'));
add_action('wp_ajax_mxchat_seo_list_posts', array($this, 'handle_seo_list_posts'));
add_action('wp_ajax_mxchat_get_default_prompt', array($this, 'handle_get_default_prompt'));
add_action('wp_ajax_mxchat_save_custom_prompt', array($this, 'handle_save_custom_prompt'));
// Background generation via loopback (nopriv because loopback doesn't carry cookies — auth via secret token)
add_action('wp_ajax_nopriv_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
add_action('wp_ajax_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
// Frontend CSS injection — outputs generated styles in
add_action('wp_head', array($this, 'inject_generated_css'));
// Hide admin bar inside content generator preview iframe (WordPress-native approach)
add_filter('show_admin_bar', array($this, 'hide_admin_bar_in_preview'));
// Disable wpautop and wptexturize for generated content.
// wpautop inserts rogue tags between block elements (section, div, etc.)
// which breaks flex/grid layouts. wptexturize converts quotes inside CSS
// values and data attributes into curly quotes, breaking functionality.
add_filter('the_content', array($this, 'protect_generated_content'), 1);
add_filter('the_content', array($this, 'restore_content_filters'), 999);
}
// ─── Content Filter Protection ──────────────────────────────────
/**
* Disable wpautop and wptexturize for MxChat-generated posts.
*
* WordPress applies these filters to the_content by default:
* - wpautop: wraps text in
tags, breaking flex/grid layouts
* - wptexturize: converts quotes to curly quotes, breaking attributes
*
* This runs at priority 1 (earliest) to remove the filters before
* they execute, then restore_content_filters() at priority 999
* re-adds them for subsequent posts (e.g. in archive/loop contexts).
*/
public function protect_generated_content($content) {
$post_id = get_the_ID();
if (!$post_id) {
return $content;
}
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
return $content;
}
// Remove wpautop and wptexturize for this post
remove_filter('the_content', 'wpautop');
remove_filter('the_content', 'wptexturize');
return $content;
}
/**
* Re-add wpautop and wptexturize after our generated content has rendered.
* This ensures other posts in the same page load (archives, widgets)
* still get normal WordPress formatting.
*/
public function restore_content_filters($content) {
if (!has_filter('the_content', 'wpautop')) {
add_filter('the_content', 'wpautop');
}
if (!has_filter('the_content', 'wptexturize')) {
add_filter('the_content', 'wptexturize');
}
return $content;
}
// ─── Frontend CSS Injection ──────────────────────────────────────
/**
* Inject generated CSS into
on the frontend.
* Legacy fallback for posts created before CSS was embedded in post_content.
* New posts (with mxchat-css CSS comment marker) skip this entirely.
*/
public function inject_generated_css() {
if (!is_singular()) {
return;
}
$post_id = get_the_ID();
if (!$post_id) {
return;
}
// Only inject on MxChat-generated posts
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
return;
}
// New-format posts have CSS embedded in post_content — skip injection
$post = get_post($post_id);
if ($post && strpos($post->post_content, '/* mxchat-css */') !== false) {
return;
}
// Legacy fallback: inject via wp_head as before
$ai_css = get_post_meta($post_id, '_mxchat_content_css', true);
$fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1';
$hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1';
echo $this->get_generated_css($fullwidth, $ai_css);
if ($hide_title) {
echo '' . "\n";
}
}
/**
* Hide the WordPress admin bar when the page is loaded inside the
* content generator preview iframe (?mxchat_preview=1).
*
* Uses the native show_admin_bar filter so WordPress never renders
* the bar at all — no CSS hacks, no flash of the bar disappearing.
*
* @param bool $show Whether to show the admin bar.
* @return bool
*/
public function hide_admin_bar_in_preview($show) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, no data processing
if (isset($_GET['mxchat_preview'])) {
return false;
}
return $show;
}
// ─── HTML Sanitization ────────────────────────────────────────────
/**
* Sanitize AI-generated HTML allowing all safe CSS properties.
* WordPress wp_kses_post() strips most inline styles (display, flex,
* background, gradient, border-radius, box-shadow, etc.) which breaks
* modern page layouts. This method uses a permissive allowlist for
* admin-generated content only.
*/
private function sanitize_generated_html($html) {
$allowed = wp_kses_allowed_html('post');
// Tags that need full style support
$styled_tags = array(
'div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'aside',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'figure',
'figcaption', 'img', 'ul', 'ol', 'li', 'blockquote', 'table', 'thead',
'tbody', 'tr', 'th', 'td', 'button', 'strong', 'em', 'br', 'hr',
'video', 'source', 'iframe', 'svg', 'path', 'circle', 'rect', 'line',
'polyline', 'polygon', 'g', 'defs', 'use', 'symbol', 'text',
);
foreach ($styled_tags as $tag) {
if (!isset($allowed[$tag])) {
$allowed[$tag] = array();
}
$allowed[$tag]['style'] = true;
$allowed[$tag]['class'] = true;
$allowed[$tag]['id'] = true;
}
// iframe attributes for video embeds (YouTube, Vimeo, etc.)
$allowed['iframe']['src'] = true;
$allowed['iframe']['width'] = true;
$allowed['iframe']['height'] = true;
$allowed['iframe']['frameborder'] = true;
$allowed['iframe']['allow'] = true;
$allowed['iframe']['allowfullscreen'] = true;
$allowed['iframe']['title'] = true;
$allowed['iframe']['loading'] = true;
// Ensure a/img have their needed attributes
$allowed['a']['href'] = true;
$allowed['a']['target'] = true;
$allowed['a']['rel'] = true;
$allowed['img']['src'] = true;
$allowed['img']['alt'] = true;
$allowed['img']['width'] = true;
$allowed['img']['height'] = true;
$allowed['img']['loading'] = true;
// SVG attributes for inline icons
foreach (array('svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g') as $svg_tag) {
$allowed[$svg_tag]['xmlns'] = true;
$allowed[$svg_tag]['viewbox'] = true;
$allowed[$svg_tag]['fill'] = true;
$allowed[$svg_tag]['stroke'] = true;
$allowed[$svg_tag]['stroke-width'] = true;
$allowed[$svg_tag]['stroke-linecap'] = true;
$allowed[$svg_tag]['stroke-linejoin'] = true;
$allowed[$svg_tag]['d'] = true;
$allowed[$svg_tag]['cx'] = true;
$allowed[$svg_tag]['cy'] = true;
$allowed[$svg_tag]['r'] = true;
$allowed[$svg_tag]['x'] = true;
$allowed[$svg_tag]['y'] = true;
$allowed[$svg_tag]['width'] = true;
$allowed[$svg_tag]['height'] = true;
$allowed[$svg_tag]['points'] = true;
$allowed[$svg_tag]['x1'] = true;
$allowed[$svg_tag]['y1'] = true;
$allowed[$svg_tag]['x2'] = true;
$allowed[$svg_tag]['y2'] = true;
$allowed[$svg_tag]['transform'] = true;
}
// Allow all safe CSS properties via the safecss filter
add_filter('safe_style_css', array($this, 'allow_all_safe_css'));
$clean = wp_kses($html, $allowed);
remove_filter('safe_style_css', array($this, 'allow_all_safe_css'));
return $clean;
}
/**
* Expand the list of allowed CSS properties for generated content.
*/
public function allow_all_safe_css($styles) {
$extra = array(
'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink',
'flex-basis', 'justify-content', 'align-items', 'align-self', 'gap', 'order',
'grid', 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row',
'grid-gap', 'grid-area',
'position', 'top', 'right', 'bottom', 'left', 'z-index',
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'background', 'background-color', 'background-image', 'background-size',
'background-position', 'background-repeat', 'background-attachment',
'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
'border-radius', 'border-color', 'border-style', 'border-width',
'box-shadow', 'text-shadow',
'color', 'font-size', 'font-weight', 'font-style', 'font-family',
'line-height', 'letter-spacing', 'text-align', 'text-decoration', 'text-transform',
'vertical-align', 'white-space', 'word-break', 'overflow', 'overflow-x', 'overflow-y',
'opacity', 'visibility', 'cursor',
'transition', 'transform', 'animation',
'object-fit', 'object-position',
'list-style', 'list-style-type',
'aspect-ratio',
);
return array_unique(array_merge($styles, $extra));
}
// ─── Generation Pipeline ───────────────────────────────────────────
/**
* Main content generation handler
*/
public function handle_generate_content() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$prompt = sanitize_textarea_field($_POST['prompt'] ?? '');
$content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
$post_status = sanitize_text_field($_POST['post_status'] ?? 'draft');
$schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
$layout = sanitize_text_field($_POST['layout'] ?? 'fullwidth');
$title_display = sanitize_text_field($_POST['title_display'] ?? 'hide');
$template_mode = sanitize_text_field($_POST['template_mode'] ?? 'off');
$custom_system_prompt = wp_unslash($_POST['custom_system_prompt'] ?? '');
if (empty($prompt)) {
wp_send_json_error(array('message' => __('Please enter a prompt.', 'mxchat')));
}
// Validate inputs
if (!in_array($content_type, array('post', 'page'), true)) {
$content_type = 'post';
}
if (!in_array($post_status, array('draft', 'publish', 'future'), true)) {
$post_status = 'draft';
}
// Generate a unique progress key and secret for the background worker
$progress_key = 'mxchat_content_progress_' . get_current_user_id() . '_' . time();
$secret = wp_generate_password(32, false);
$this->update_progress($progress_key, 'starting', __('Starting generation...', 'mxchat'), 5);
// Store generation parameters for the background worker
$params = array(
'secret' => $secret,
'user_id' => get_current_user_id(),
'prompt' => $prompt,
'content_type' => $content_type,
'post_status' => $post_status,
'schedule_date' => $schedule_date,
'layout' => $layout,
'title_display' => $title_display,
'template_mode' => $template_mode,
'custom_system_prompt' => $custom_system_prompt,
);
set_transient($progress_key . '_params', $params, 600);
// Send JSON response to the browser immediately, then continue
// generation in the same PHP process. This avoids loopback requests
// which fail behind Cloudflare, CDNs, and on shared hosting.
//
// Strategy (works on all hosting environments):
// 1. litespeed_finish_request() — LiteSpeed servers (HostGator, etc.)
// 2. fastcgi_finish_request() — Nginx + PHP-FPM (most VPS/cloud hosts)
// 3. Connection: close + output buffer flush — Apache mod_php, CGI, any other SAPI
//
// All three approaches send the response to the client and allow PHP
// to continue executing in the background.
ignore_user_abort(true);
if (function_exists('set_time_limit')) {
set_time_limit(300);
}
$response_json = wp_json_encode(array('success' => true, 'data' => array('progress_key' => $progress_key)));
if (function_exists('litespeed_finish_request')) {
header('Content-Type: application/json; charset=utf-8');
echo $response_json;
litespeed_finish_request();
} elseif (function_exists('fastcgi_finish_request')) {
header('Content-Type: application/json; charset=utf-8');
echo $response_json;
fastcgi_finish_request();
} else {
// Universal fallback: close the connection via headers + output buffer flush.
// Works on Apache mod_php, CGI, and any SAPI that doesn't have a finish function.
header('Content-Type: application/json; charset=utf-8');
header('Connection: close');
header('Content-Encoding: none');
// Clear any existing output buffers
while (ob_get_level() > 0) {
ob_end_clean();
}
ob_start();
echo $response_json;
$size = ob_get_length();
header('Content-Length: ' . $size);
ob_end_flush();
flush();
}
// Client has received the response and is now polling for progress.
// Run generation inline in this same PHP process.
$this->run_background_generation($progress_key, $params);
die();
}
/**
* Background content generation handler.
* Called via non-blocking loopback from handle_generate_content().
* Authenticated via secret token stored in transient (no nonce/cookie needed).
*/
public function handle_generate_content_background() {
$progress_key = sanitize_text_field($_POST['progress_key'] ?? '');
$secret = sanitize_text_field($_POST['secret'] ?? '');
if (empty($progress_key) || empty($secret)) {
die();
}
// Validate the secret token
$params = get_transient($progress_key . '_params');
if (!$params || !isset($params['secret']) || $params['secret'] !== $secret) {
die();
}
// One-time use — delete params transient
delete_transient($progress_key . '_params');
// Set up execution environment
if (function_exists('set_time_limit')) {
set_time_limit(300);
}
ignore_user_abort(true);
// Restore the original user context
wp_set_current_user($params['user_id']);
$this->run_background_generation($progress_key, $params);
die();
}
/**
* Core generation logic — used by both loopback and inline execution paths.
*/
private function run_background_generation($progress_key, $params) {
$prompt = $params['prompt'];
$content_type = $params['content_type'];
$post_status = $params['post_status'];
$schedule_date = $params['schedule_date'];
$layout = $params['layout'];
$title_display = $params['title_display'];
$template_mode = $params['template_mode'] ?? 'off';
$custom_system_prompt = $params['custom_system_prompt'] ?? '';
$this->update_progress($progress_key, 'planning', __('Planning content structure...', 'mxchat'), 10);
// Step 1: Plan the content
$plan = $this->plan_content($prompt, $content_type);
if (is_wp_error($plan)) {
$this->update_progress($progress_key, 'error', $plan->get_error_message(), 0);
return;
}
$this->update_progress($progress_key, 'images', __('Generating images...', 'mxchat'), 30);
// Step 2: Generate images
$image_urls = $this->generate_content_images($plan, $progress_key);
$this->update_progress($progress_key, 'writing', __('Writing full content...', 'mxchat'), 60);
// Step 3: Generate full HTML content
$html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt, $custom_system_prompt);
if (is_wp_error($html_content)) {
$this->update_progress($progress_key, 'error', $html_content->get_error_message(), 0);
return;
}
$this->update_progress($progress_key, 'creating', __('Creating WordPress post...', 'mxchat'), 85);
// Step 4: Create the WordPress post/page
// Extract AI CSS — saved to post meta for edit workflow, and embedded in post_content for portability
$ai_css = $this->extract_css($html_content);
$html_without_style = preg_replace('/\n";
}
$full_content_for_ai .= $clean_content;
$user_message = "Current post title: " . $current_title . "\n\nCurrent content (style block + HTML):\n" . $full_content_for_ai . "\n\nUser edit request: " . $edit_instruction;
$messages = array(
array('role' => 'user', 'content' => $user_message),
);
// Allow add-ons to handle the edit via tool calling (str_replace, etc.)
// Filter returns null to fall through to the default full-rewrite approach.
$result = apply_filters('mxchat_content_tool_edit', null, $full_content_for_ai, $edit_instruction, $current_title);
if ($result === null) {
// Default: AI rewrites entire page
$result = $this->call_content_model($system_prompt, $messages, 16384);
}
if (is_wp_error($result)) {
wp_send_json_error(array('message' => $result->get_error_message()));
}
// Clean up code fences if present
$result = trim($result);
$result = preg_replace('/^```(?:html)?\s*/i', '', $result);
$result = preg_replace('/\s*```\s*$/', '', $result);
// Strip HTML comments
$result = preg_replace('//s', '', $result);
// Extract title if it was changed (look for first h1)
$new_title = $current_title;
if (preg_match('/]*>(.*?)<\/h1>/is', $result, $title_match)) {
$new_title = wp_strip_all_tags($title_match[1]);
}
// Extract CSS → meta (for edit workflow), embed full CSS in post_content (for portability)
$ai_css = $this->extract_css($result);
$html_without_style = preg_replace('/\n";
return $css;
}
// ─── Layout / Fullwidth Settings ──────────────────────────────────
/**
* Build a single ';
return $css;
}
/**
* Layer 1: Theme & page builder fullwidth padding resets.
*/
private function get_fullwidth_reset_css() {
return '/* MxChat — Fullwidth Theme Reset */
/* Generic WordPress themes */
.entry-content-wrap,
.entry-content,
.post-inner .entry-content,
.container.site-content,
article .entry-content,
.content-area .site-main,
.single-content .entry-content,
.type-post .entry-content,
.type-page .entry-content,
.page .entry-content,
.single .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Outer wrappers that themes use to add spacing */
.site-main > article,
.content-area,
.site-content,
#content,
#primary,
.hentry,
.post,
.page .post,
.single .post {
padding: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
max-width: 100% !important;
}
/* Astra */
.ast-container .entry-content,
.site-content .ast-container,
.ast-separate-container .ast-article-single,
.ast-separate-container .ast-article-post,
.ast-separate-container .ast-article-page {
padding: 0 !important;
margin: 0 auto !important;
max-width: 100% !important;
width: 100% !important;
background: transparent !important;
}
.ast-separate-container .entry-content {
margin: 0 !important;
}
/* GeneratePress */
.inside-article .entry-content,
.generate-columns-container,
.inside-article {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Kadence */
.kb-row-layout-wrap,
.entry-content-wrap,
.content-container.site-container {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
.content-style-unboxed .entry:not(.loop-entry),
.content-style-boxed .entry:not(.loop-entry) {
box-shadow: none !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
/* OceanWP */
.ocean-content .entry,
#content-wrap .container {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Neve */
.nv-single-post-wrap .entry-content,
.nv-content-wrap .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Hello Elementor / Elementor default theme */
.site-main .elementor-section-wrap,
.elementor-page .page-content .entry-content,
.elementor-default .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Bricks Builder */
.brxe-post-content .entry-content,
.bricks-layout-wrapper .entry-content,
.brxe-container .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Divi */
.et_pb_post .entry-content,
#main-content .container .entry-content,
.et_full_width_page .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Beaver Builder */
.fl-post-content .entry-content,
.fl-content-full .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Blocksy */
.entry-content[data-source],
.site-main > article > .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* Spectra / starter templates */
.uagb-body-wrapper .entry-content,
.starter-template-content .entry-content {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* WordPress Block Themes (Twenty Twenty-Two → Twenty Twenty-Five)
These use theme.json layout constraints rather than classic .entry-content,
so the selectors above do not apply — neutralize them here. */
.wp-site-blocks,
.wp-block-post-content,
.wp-block-group.has-global-padding,
.has-global-padding {
padding-left: 0 !important;
padding-right: 0 !important;
max-width: 100% !important;
}
.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)),
.wp-block-post-content > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
max-width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
';
}
/**
* Layer 2: CSS isolation + responsive overrides for mxg- classes.
*/
private function get_isolation_css() {
return '/* MxChat — CSS Isolation & Responsive Overrides */
.mxg-wrapper { box-sizing: border-box; }
.mxg-wrapper *, .mxg-wrapper *::before, .mxg-wrapper *::after { box-sizing: inherit; }
.mxg-wrapper img { max-width: 100%; height: auto; }
.mxg-wrapper section { clear: both; }
.mxg-row { display: flex; flex-wrap: wrap; }
.mxg-col { min-width: 0; }
.mxg-grid { display: flex; flex-wrap: wrap; }
.mxg-container { box-sizing: border-box; width: 100%; }
@media (max-width: 768px) {
.mxg-row { flex-direction: column !important; gap: 24px !important; }
.mxg-col { flex: 1 1 100% !important; width: 100% !important; max-width: 100% !important; }
.mxg-hero-heading { font-size: 2.2rem !important; }
.mxg-section-heading { font-size: 1.6rem !important; }
.mxg-container { padding-left: 20px !important; padding-right: 20px !important; }
.mxg-card { flex: 1 1 100% !important; }
.mxg-grid { gap: 16px !important; }
}
@media (max-width: 480px) {
.mxg-hero-heading { font-size: 1.75rem !important; }
.mxg-section-heading { font-size: 1.35rem !important; }
.mxg-container { padding-left: 16px !important; padding-right: 16px !important; }
}
';
}
/**
* Title-hiding CSS for WordPress themes.
*/
private function get_title_hide_css() {
return '/* MxChat — Hide Title */
.entry-title,
.page-title,
.post-title,
.wp-block-post-title,
.ast-title-with-post-meta-wrapper,
.ast-the-title,
.generate-page-header .page-hero,
.entry-header .entry-title,
.entry-hero .entry-title,
.kadence-page-title,
.wp-site-blocks .entry-title,
.ocean-single-post-header,
.page-header,
.nv-page-title-wrap,
.nv-post-title,
.elementor-page-title,
.et_pb_title_container .entry-title,
.brxe-post-title,
[data-hero] .page-title,
.hero-section .page-title,
.entry-header {
display: none !important;
}
';
}
/**
* Apply layout settings via theme-specific and builder-specific post meta.
*
* Supports: Astra, GeneratePress, Kadence, OceanWP, Neve, Blocksy,
* Elementor, Bricks Builder, Divi, Beaver Builder, and generic WordPress.
*
* Page builders that are installed but NOT used to edit this post will
* still respect standard WordPress post_content — this method sets the
* right meta so the theme renders it fullwidth without sidebar.
*/
private function apply_layout_settings($post_id, $layout, $title_display) {
$is_fullwidth = ($layout === 'fullwidth');
$hide_title = ($title_display === 'hide');
// ── Astra Theme ──
if (defined('ASTRA_THEME_VERSION') || get_template() === 'astra') {
if ($is_fullwidth) {
update_post_meta($post_id, 'site-content-layout', 'page-builder');
update_post_meta($post_id, 'site-sidebar-layout', 'no-sidebar');
}
if ($hide_title) {
update_post_meta($post_id, 'site-post-title', 'disabled');
}
}
// ── GeneratePress Theme ──
if (defined('GENERATE_VERSION') || get_template() === 'generatepress') {
if ($is_fullwidth) {
update_post_meta($post_id, '_generate-sidebar-layout-meta', 'no-sidebar');
update_post_meta($post_id, '_generate-full-width-content', 'true');
}
if ($hide_title) {
update_post_meta($post_id, '_generate-disable-title', 'true');
}
}
// ── Kadence Theme ──
if (class_exists('Kadence\\Theme') || get_template() === 'kadence') {
if ($is_fullwidth) {
update_post_meta($post_id, '_kad_post_layout', 'fullwidth');
update_post_meta($post_id, '_kad_post_content_style', 'unboxed');
}
if ($hide_title) {
update_post_meta($post_id, '_kad_post_title', 'hide');
}
}
// ── OceanWP Theme ──
if (class_exists('Ocean_Extra') || get_template() === 'oceanwp') {
if ($is_fullwidth) {
update_post_meta($post_id, 'oceanwp_post_layout', 'full-width');
update_post_meta($post_id, 'ocean_content_layout', 'full-width');
}
if ($hide_title) {
update_post_meta($post_id, 'oceanwp_disable_title', 'on');
}
}
// ── Neve Theme ──
if (get_template() === 'neve') {
if ($is_fullwidth) {
update_post_meta($post_id, 'neve_meta_sidebar', 'full-width');
update_post_meta($post_id, 'neve_meta_container', 'full-width');
}
if ($hide_title) {
update_post_meta($post_id, 'neve_meta_disable_title', 'on');
}
}
// ── Blocksy Theme ──
if (get_template() === 'blocksy') {
if ($is_fullwidth) {
update_post_meta($post_id, 'page_structure_type', 'type-4');
}
if ($hide_title) {
update_post_meta($post_id, 'disable_header', 'yes');
}
}
// ── Elementor Canvas/Full Width ──
// When Elementor is installed, use its Canvas template for the cleanest
// fullwidth output (no header/footer/sidebar chrome from the theme).
// The post still uses standard post_content — Elementor only takes over
// rendering when _elementor_edit_mode is set (which we don't set).
if (defined('ELEMENTOR_VERSION') && $is_fullwidth) {
$post_type = get_post_type($post_id);
$templates = wp_get_theme()->get_page_templates(get_post($post_id), $post_type);
// Prefer Elementor Canvas (no theme chrome at all)
if (isset($templates['elementor_canvas'])) {
update_post_meta($post_id, '_wp_page_template', 'elementor_canvas');
} elseif (isset($templates['elementor_header_footer'])) {
update_post_meta($post_id, '_wp_page_template', 'elementor_header_footer');
}
}
// ── Divi Theme / Divi Builder ──
if (defined('ET_BUILDER_VERSION') || get_template() === 'Divi') {
if ($is_fullwidth) {
update_post_meta($post_id, '_et_pb_page_layout', 'et_full_width_page');
update_post_meta($post_id, '_et_pb_side_nav', 'off');
}
if ($hide_title) {
update_post_meta($post_id, '_et_pb_show_title', 'off');
}
}
// ── Beaver Builder ──
if (class_exists('FLBuilder') || class_exists('FLBuilderLoader')) {
if ($is_fullwidth) {
// Beaver Themer uses this meta for sidebar control
update_post_meta($post_id, '_fl_builder_sidebar', 'no_sidebar');
}
}
// ── Bricks Builder ──
// Bricks uses its own rendering when _bricks_editor_mode is set.
// For standard WP content, it falls through to the theme's template.
// No special meta needed — our CSS resets and wp_head injection handle it.
// ── Generic full-width page template ──
// Only set _wp_page_template if the theme actually has a matching template file
// AND we haven't already set one above (e.g. Elementor Canvas).
// Setting a non-existent template causes "Invalid page template" errors on wp_update_post().
// Our CSS injection via wp_head already handles fullwidth layout for all themes.
if ($is_fullwidth) {
$current_template = get_post_meta($post_id, '_wp_page_template', true);
if (empty($current_template) || $current_template === 'default') {
$theme_templates = wp_get_theme()->get_page_templates(get_post($post_id));
foreach ($theme_templates as $file => $label) {
if (stripos($file, 'full') !== false && stripos($file, 'width') !== false) {
update_post_meta($post_id, '_wp_page_template', $file);
break;
}
}
}
}
}
// ─── SEO Metadata ──────────────────────────────────────────────────
/**
* Step 5: Fill SEO metadata for the generated post
*/
private function fill_seo_metadata($post_id, $plan) {
$meta_description = $plan['meta_description'] ?? '';
$keywords = $plan['keywords'] ?? array();
$focus_keyword = !empty($keywords) ? $keywords[0] : '';
$keywords_string = implode(', ', $keywords);
$this->set_meta_description($post_id, $meta_description);
$this->set_focus_keyword($post_id, !empty($focus_keyword) ? $focus_keyword : $keywords_string);
$this->set_seo_title($post_id, $plan['title'] ?? '');
}
/**
* Write the SEO title to the active SEO plugin's title postmeta so
* RankMath / Yoast / AIOSEO don't fall back to the bare post_title.
* (plan-mxchat-20260510-b89332)
*
* v1: use the generated post_title as the SEO title. A future plan may
* add a separately-generated 50-58 char SEO-optimized title.
*/
private function set_seo_title($post_id, $title) {
$title = sanitize_text_field($title);
if (empty($title)) {
return;
}
$plugin = $this->get_seo_plugin();
$keys = array(
'rankmath' => 'rank_math_title',
'yoast' => '_yoast_wpseo_title',
'aioseo' => '_aioseo_title',
);
if (isset($keys[$plugin])) {
update_post_meta($post_id, $keys[$plugin], $title);
}
}
/**
* Walk the rendered body HTML and copy the inline `
` text
* for each generated image into its attachment's _wp_attachment_image_alt
* postmeta. Matches inline images to attachments by URL.
* (plan-mxchat-20260510-2be2da)
*/
private function sync_image_alts_from_content($content, $image_urls) {
if (empty($content) || empty($image_urls)) {
return;
}
// Build a url => attachment_id lookup for fast matching.
$by_url = array();
foreach ($image_urls as $img) {
if (!empty($img['url']) && !empty($img['attachment_id'])) {
$by_url[$img['url']] = (int) $img['attachment_id'];
}
}
if (empty($by_url)) {
return;
}
if (!preg_match_all('/
]*>/i', $content, $matches)) {
return;
}
foreach ($matches[0] as $img_tag) {
if (!preg_match('/\bsrc\s*=\s*"([^"]+)"/i', $img_tag, $src_match)) {
continue;
}
$src = html_entity_decode($src_match[1], ENT_QUOTES);
if (!isset($by_url[$src])) {
continue;
}
if (!preg_match('/\balt\s*=\s*"([^"]*)"/i', $img_tag, $alt_match)) {
continue;
}
$alt = sanitize_text_field(html_entity_decode($alt_match[1], ENT_QUOTES));
if ($alt !== '') {
update_post_meta($by_url[$src], '_wp_attachment_image_alt', $alt);
}
}
}
// ─── AI Model Caller ────────────────────────────────────────────────
/**
* Call the configured content model
*/
/**
* Provider slugs (MxChat_Model_Catalog keys) that call_content_model() can
* dispatch to. MUST mirror the if/elseif chain below — the Content Model
* picker derives its provider set from this list (plan ccb751), so a
* provider added to the dispatch without updating this is never offered,
* and one added here without dispatch support would break generation.
* OpenRouter and the custom OpenAI-compatible provider are absent on
* purpose: their pseudo-model ids would fall through to the OpenAI default
* branch with the wrong key and endpoint.
*/
public static function supported_content_providers() {
return array('openai', 'claude', 'gemini', 'xai', 'deepseek');
}
private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
$options = get_option('mxchat_options', array());
$model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol';
// Determine provider from model name
if ($this->is_claude_model($model)) {
return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
} elseif ($this->is_gemini_model($model)) {
return $this->call_gemini($model, $options['gemini_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
} elseif ($this->is_xai_model($model)) {
return $this->call_openai_compatible($model, $options['xai_api_key'] ?? '', 'https://api.x.ai/v1/chat/completions', $system_prompt, $messages, $max_tokens);
} elseif ($this->is_deepseek_model($model)) {
return $this->call_openai_compatible($model, $options['deepseek_api_key'] ?? '', 'https://api.deepseek.com/chat/completions', $system_prompt, $messages, $max_tokens);
} else {
// Default: OpenAI
return $this->call_openai_compatible($model, $options['api_key'] ?? '', 'https://api.openai.com/v1/chat/completions', $system_prompt, $messages, $max_tokens);
}
}
/**
* Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
*/
/**
* plan-mxchat-20260714-dcb71c: frozen pre-dcb71c 'content' reasoning ladder,
* used only when the core model catalog is unavailable. Byte-identical to the
* old inline if/elseif at the call site.
*/
private function mxchat_cg_reasoning_effort_fallback($model) {
if (strpos($model, 'gpt-5') !== 0 && strpos($model, 'gpt-6') !== 0) return null; // gpt-6 too (plan 45c22d)
if ($model === 'gpt-5.2' || $model === 'gpt-5.1-chat-latest') return null;
if ($model === 'gpt-5.1-2025-11-13') return 'low';
if ($model === 'gpt-5.5') return 'low';
if ($model === 'gpt-5.4') return 'low';
if (in_array($model, array('gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) return 'low'; // plan 45c22d
if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
return 'minimal';
}
private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
if (empty($api_key)) {
return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
}
$formatted = array();
$formatted[] = array('role' => 'system', 'content' => $system_prompt);
foreach ($messages as $msg) {
$formatted[] = array(
'role' => $msg['role'] ?? 'user',
'content' => $msg['content'] ?? '',
);
}
// GPT-5.x models require max_completion_tokens and only support temperature=1.
// Token key, temperature gate, and reasoning_effort are sourced from the
// core model catalog (plan-dcb71c); frozen fallbacks preserve exact
// pre-dcb71c behavior if the catalog class is unavailable.
$is_gpt5 = strpos($model, 'gpt-5') === 0 || strpos($model, 'gpt-6') === 0; // gpt-6 too (plan 45c22d)
$catalog = class_exists('MxChat_Model_Catalog');
$token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param'))
? MxChat_Model_Catalog::openai_token_param($model)
: ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens');
$body = array(
'model' => $model,
'messages' => $formatted,
$token_key => $max_tokens,
'stream' => false,
);
$temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
? MxChat_Model_Catalog::supports_temperature($model)
: !$is_gpt5;
if ($temp_ok) {
$body['temperature'] = 0.7;
}
$effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for'))
? MxChat_Model_Catalog::reasoning_effort_for($model, 'content')
: $this->mxchat_cg_reasoning_effort_fallback($model);
if ($effort !== null) {
$body['reasoning_effort'] = $effort;
}
// DeepSeek V4 defaults to thinking mode ON (temperature ignored and
// reasoning eats the token budget); generation wants the legacy
// deepseek-chat semantics = non-thinking. Endpoint-gated because this
// helper is shared with OpenAI and xAI.
if (strpos($endpoint, 'api.deepseek.com') !== false) {
$body['thinking'] = array('type' => 'disabled');
}
// Scale timeout with token count — large generation calls need more time
$timeout = ($max_tokens > 8000) ? 300 : 120;
$response = wp_remote_post($endpoint, array(
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode($body),
'timeout' => $timeout,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
// plan-25b972 self-heal: supported reasoning_effort VALUES are per-model;
// a 400 rejecting the value we sent (stale catalog entry / provider
// drift) is deterministic — strip the param and retry ONCE.
if ($status_code === 400
&& isset($body['reasoning_effort'])
&& isset($decoded['error']['message'])
&& is_string($decoded['error']['message'])
&& preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).',
$model, $body['reasoning_effort']
));
}
unset($body['reasoning_effort']);
$response = wp_remote_post($endpoint, array(
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode($body),
'timeout' => $timeout,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
}
if ($status_code !== 200) {
$error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
return new WP_Error('api_error', $error_msg);
}
if (isset($decoded['choices'][0]['message']['content'])) {
return trim($decoded['choices'][0]['message']['content']);
}
return new WP_Error('unexpected_response', __('Unexpected API response format.', 'mxchat'));
}
/**
* Call Claude (Anthropic) API
*/
private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
// Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
// Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
if (empty($api_key)) {
return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
}
$formatted = array();
foreach ($messages as $msg) {
$role = $msg['role'] ?? 'user';
if (!in_array($role, array('user', 'assistant'), true)) {
$role = 'user';
}
$formatted[] = array(
'role' => $role,
'content' => $msg['content'] ?? '',
);
}
$body = array(
'model' => $model,
'max_tokens' => $max_tokens,
'temperature' => 0.7,
'messages' => $formatted,
// Prompt-cache breakpoint (plan 1ff43b): generation pipelines reuse
// the same system prompt across consecutive requests within the
// 5-minute cache window. Silently no-ops below the model minimum.
'system' => trim((string) $system_prompt) === '' ? $system_prompt : array(
array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')),
),
);
// Anthropic removed temperature on Opus 4.7+ flagships (400 if sent) —
// sourced from the core model catalog (plan-dcb71c); frozen list fallback.
$omit_temp = (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
? !MxChat_Model_Catalog::supports_temperature($model)
: in_array($model, array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5'), true);
if ($omit_temp) {
unset($body['temperature']);
}
$timeout = ($max_tokens > 8000) ? 300 : 120;
$response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
'headers' => array(
'Content-Type' => 'application/json',
'x-api-key' => $api_key,
'anthropic-version' => '2023-06-01',
),
'body' => wp_json_encode($body),
'timeout' => $timeout,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if ($status_code !== 200) {
$error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code;
return new WP_Error('claude_error', $error_msg);
}
// claude-fable-5 prepends a thinking block to content — take the
// first TEXT block, not content[0].
if (isset($decoded['content']) && is_array($decoded['content'])) {
foreach ($decoded['content'] as $block) {
if (isset($block['type'], $block['text']) && $block['type'] === 'text') {
return trim($block['text']);
}
}
}
return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat'));
}
/**
* Call Gemini API
*/
private function call_gemini($model, $api_key, $system_prompt, $messages, $max_tokens) {
if ($model === 'gemini-3-pro-preview') {
$model = 'gemini-3.1-pro-preview';
}
if (empty($api_key)) {
return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
}
$formatted = array();
// System instructions as first user message
$formatted[] = array(
'role' => 'user',
'parts' => array(array('text' => "[System Instructions] " . $system_prompt)),
);
$formatted[] = array(
'role' => 'model',
'parts' => array(array('text' => "I understand and will follow these instructions.")),
);
foreach ($messages as $msg) {
$role = ($msg['role'] ?? 'user') === 'assistant' ? 'model' : 'user';
$formatted[] = array(
'role' => $role,
'parts' => array(array('text' => $msg['content'] ?? '')),
);
}
$body = array(
'contents' => $formatted,
'generationConfig' => array(
'temperature' => 0.7,
'topP' => 0.95,
'topK' => 40,
'maxOutputTokens' => $max_tokens,
),
);
$api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
$url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key={$api_key}";
$timeout = ($max_tokens > 8000) ? 300 : 120;
$response = wp_remote_post($url, array(
'headers' => array('Content-Type' => 'application/json'),
'body' => wp_json_encode($body),
'timeout' => $timeout,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if ($status_code !== 200) {
$error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code;
return new WP_Error('gemini_error', $error_msg);
}
if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) {
return trim($decoded['candidates'][0]['content']['parts'][0]['text']);
}
return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat'));
}
// ─── Model Detection Helpers ────────────────────────────────────────
private function is_claude_model($model) {
return strpos($model, 'claude') === 0;
}
private function is_gemini_model($model) {
return strpos($model, 'gemini') === 0;
}
private function is_xai_model($model) {
return strpos($model, 'grok') === 0;
}
private function is_deepseek_model($model) {
return strpos($model, 'deepseek') === 0;
}
// ─── Content Settings Save ────────────────────────────────────────
/**
* Dedicated handler for saving content generator settings.
* Bypasses the main settings handler entirely.
*/
public function handle_save_content_setting() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$field = sanitize_text_field($_POST['field'] ?? '');
$value = sanitize_text_field($_POST['value'] ?? '');
$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');
if (!in_array($field, $allowed_fields, true)) {
wp_send_json_error(array('message' => __('Invalid field.', 'mxchat')));
}
// Toggle fields
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)) {
$value = ($value === 'on') ? 'on' : 'off';
}
// Enum field: image quality
if ($field === 'content_image_quality') {
$value = in_array($value, array('auto', 'low', 'medium', 'high'), true) ? $value : 'auto';
}
// Integer field: images per article (clamp 1–5)
if ($field === 'content_image_count') {
$value = (string) max(1, min(5, (int) $value));
}
$options = get_option('mxchat_options', array());
$options[$field] = $value;
update_option('mxchat_options', $options);
wp_send_json_success(array('message' => __('Setting saved.', 'mxchat')));
}
// ─── Progress Tracking ─────────────────────────────────────────────
/**
* Update generation progress.
* Uses wp_options directly (not transients) to avoid object cache
* stale-read issues across the background worker and polling processes.
*/
private function update_progress($key, $step, $message, $percent, $result = null) {
global $wpdb;
$data = array(
'step' => $step,
'message' => $message,
'percent' => $percent,
'updated' => time(),
);
if ($result !== null) {
$data['result'] = $result;
}
$option_name = '_mxchat_progress_' . $key;
$serialized = maybe_serialize($data);
// Direct DB write — bypasses object cache entirely
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->options WHERE option_name = %s",
$option_name
));
if ($exists) {
$wpdb->update(
$wpdb->options,
array('option_value' => $serialized),
array('option_name' => $option_name)
);
} else {
$wpdb->insert(
$wpdb->options,
array(
'option_name' => $option_name,
'option_value' => $serialized,
'autoload' => 'no',
)
);
}
// Also bust the object cache in case anything reads via get_option()
wp_cache_delete($option_name, 'options');
}
/**
* Read generation progress.
* Direct DB read — bypasses object cache for guaranteed freshness.
*/
private function get_progress($key) {
global $wpdb;
$option_name = '_mxchat_progress_' . $key;
$value = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM $wpdb->options WHERE option_name = %s",
$option_name
));
if ($value === null) {
return false;
}
return maybe_unserialize($value);
}
/**
* Delete generation progress (cleanup).
*/
private function delete_progress($key) {
global $wpdb;
$option_name = '_mxchat_progress_' . $key;
$wpdb->delete($wpdb->options, array('option_name' => $option_name));
wp_cache_delete($option_name, 'options');
}
// ─── Content History ──────────────────────────────────────────────
/**
* Return paginated list of AI-generated posts for the History tab.
*/
public function handle_content_history() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$page = max(1, intval($_POST['page'] ?? 1));
$per_page = 10;
$query = new WP_Query(array(
'post_type' => array('post', 'page'),
'post_status' => array('publish', 'draft', 'future', 'pending', 'private'),
'meta_key' => '_mxchat_generated',
'meta_value' => '1',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'date',
'order' => 'DESC',
));
$items = array();
foreach ($query->posts as $post) {
$thumb = get_the_post_thumbnail_url($post->ID, 'thumbnail');
$items[] = array(
'post_id' => $post->ID,
'title' => $post->post_title,
'status' => $post->post_status,
'post_type' => $post->post_type,
'date' => get_the_date('M j, Y', $post),
'thumbnail' => $thumb ? $thumb : '',
'permalink' => get_permalink($post->ID),
);
}
wp_send_json_success(array(
'items' => $items,
'total' => (int) $query->found_posts,
'total_pages' => (int) $query->max_num_pages,
'current_page' => $page,
));
}
/**
* Move an AI-generated post to the trash.
*/
public function handle_delete_content() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$post_id = intval($_POST['post_id'] ?? 0);
if (!$post_id) {
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
}
// Only allow deleting MxChat-generated content
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
}
$result = wp_trash_post($post_id);
if (!$result) {
wp_send_json_error(array('message' => __('Failed to delete post.', 'mxchat')));
}
wp_send_json_success(array('post_id' => $post_id));
}
/**
* Update the status of an AI-generated post (draft, publish, future).
*/
public function handle_update_post_status() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$post_id = intval($_POST['post_id'] ?? 0);
$new_status = sanitize_text_field($_POST['new_status'] ?? '');
$schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
if (!$post_id) {
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
}
// Only allow updating MxChat-generated content
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
}
if (!in_array($new_status, array('draft', 'publish', 'future'), true)) {
wp_send_json_error(array('message' => __('Invalid status.', 'mxchat')));
}
$post_args = array('ID' => $post_id, 'post_status' => $new_status);
// Scheduled: require future date
if ($new_status === 'future') {
if (empty($schedule_date)) {
wp_send_json_error(array('message' => __('A schedule date is required.', 'mxchat')));
}
$post_args['post_date'] = $schedule_date;
$post_args['post_date_gmt'] = get_gmt_from_date($schedule_date);
$post_args['edit_date'] = true;
}
// Transitioning FROM future to draft/publish: reset post_date to now
if ($new_status !== 'future') {
$current = get_post($post_id);
if ($current && $current->post_status === 'future') {
$post_args['post_date'] = current_time('mysql');
$post_args['post_date_gmt'] = current_time('mysql', true);
$post_args['edit_date'] = true;
}
}
$result = wp_update_post($post_args, true);
if (is_wp_error($result)) {
wp_send_json_error(array('message' => $result->get_error_message()));
}
// Re-read to get confirmed status (WP may auto-publish if schedule date is past)
$updated = get_post($post_id);
wp_send_json_success(array(
'post_id' => $post_id,
'status' => $updated->post_status,
));
}
/**
* Load an existing AI-generated post into the editor state.
* Returns the same data shape as the generation success response.
*/
public function handle_load_post_for_edit() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$post_id = intval($_POST['post_id'] ?? 0);
if (!$post_id) {
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
}
$post = get_post($post_id);
if (!$post) {
wp_send_json_error(array('message' => __('Post not found.', 'mxchat')));
}
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
}
$preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
$edit_url = admin_url('post.php?post=' . $post_id . '&action=edit');
$permalink = get_permalink($post_id);
// Discover images — prefer stored IDs, fall back to post_parent query
$images = $this->discover_post_images($post_id);
wp_send_json_success(array(
'post_id' => $post_id,
'preview_url' => $preview_url,
'edit_url' => $edit_url,
'permalink' => $permalink,
'title' => $post->post_title,
'status' => $post->post_status,
'images' => $images,
'meta' => array(
'description' => $this->get_meta_description($post_id),
'keyword' => $this->get_focus_keyword($post_id),
'excerpt' => $post->post_excerpt,
),
));
}
/**
* Discover images associated with a generated post.
* Uses stored attachment IDs (most reliable), falls back to post_parent query.
*/
private function discover_post_images($post_id) {
$images = array();
$stored_ids = get_post_meta($post_id, '_mxchat_image_ids', true);
if (!empty($stored_ids) && is_array($stored_ids)) {
// Primary: use stored attachment IDs
foreach ($stored_ids as $att_id) {
$att_url = wp_get_attachment_url($att_id);
if ($att_url) {
$thumb_url = wp_get_attachment_image_url($att_id, 'medium');
$images[] = array(
'url' => $att_url,
'thumbnail' => $thumb_url ?: $att_url,
'attachment_id' => $att_id,
);
}
}
} else {
// Fallback: query by post_parent + meta key
$attachments = get_posts(array(
'post_type' => 'attachment',
'post_mime_type' => 'image',
'posts_per_page' => -1,
'post_parent' => $post_id,
'meta_key' => '_mxchat_image_prompt',
'meta_compare' => 'EXISTS',
'orderby' => 'date',
'order' => 'ASC',
));
foreach ($attachments as $att) {
$att_url = wp_get_attachment_url($att->ID);
if ($att_url) {
$thumb_url = wp_get_attachment_image_url($att->ID, 'medium');
$images[] = array(
'url' => $att_url,
'thumbnail' => $thumb_url ?: $att_url,
'attachment_id' => $att->ID,
);
}
}
}
// Fallback: include featured image if nothing else found
$featured_id = get_post_thumbnail_id($post_id);
if (empty($images) && $featured_id) {
$feat_url = wp_get_attachment_url($featured_id);
$feat_thumb = wp_get_attachment_image_url($featured_id, 'medium');
if ($feat_url) {
$images[] = array(
'url' => $feat_url,
'thumbnail' => $feat_thumb ?: $feat_url,
'attachment_id' => $featured_id,
);
}
}
return $images;
}
/**
* Get the focus keyword (first keyword from comma-separated list).
*/
private function get_seo_plugin() {
if (class_exists('RankMath')) return 'rankmath';
if (defined('WPSEO_VERSION')) return 'yoast';
if (function_exists('aioseo')) return 'aioseo';
return 'none';
}
private function get_meta_description($post_id) {
$plugin = $this->get_seo_plugin();
$keys = array(
'rankmath' => 'rank_math_description',
'yoast' => '_yoast_wpseo_metadesc',
'aioseo' => '_aioseo_description',
);
if (isset($keys[$plugin])) {
$val = get_post_meta($post_id, $keys[$plugin], true);
if (!empty($val)) return $val;
}
return get_post_meta($post_id, '_mxchat_meta_description', true);
}
private function set_meta_description($post_id, $value) {
$value = sanitize_text_field($value);
$plugin = $this->get_seo_plugin();
$keys = array(
'rankmath' => 'rank_math_description',
'yoast' => '_yoast_wpseo_metadesc',
'aioseo' => '_aioseo_description',
);
if (isset($keys[$plugin])) {
update_post_meta($post_id, $keys[$plugin], $value);
} else {
update_post_meta($post_id, '_mxchat_meta_description', $value);
}
}
private function get_focus_keyword($post_id) {
$plugin = $this->get_seo_plugin();
$keys = array(
'rankmath' => 'rank_math_focus_keyword',
'yoast' => '_yoast_wpseo_focuskw',
);
if (isset($keys[$plugin])) {
$val = get_post_meta($post_id, $keys[$plugin], true);
if (!empty($val)) {
$parts = explode(',', $val);
return trim($parts[0]);
}
}
$keywords = get_post_meta($post_id, '_mxchat_keywords', true);
if (!empty($keywords)) {
$parts = explode(',', $keywords);
return trim($parts[0]);
}
return '';
}
private function set_focus_keyword($post_id, $value) {
$value = sanitize_text_field($value);
$plugin = $this->get_seo_plugin();
$keys = array(
'rankmath' => 'rank_math_focus_keyword',
'yoast' => '_yoast_wpseo_focuskw',
);
if (isset($keys[$plugin])) {
update_post_meta($post_id, $keys[$plugin], $value);
} else {
update_post_meta($post_id, '_mxchat_keywords', $value);
}
}
// ─── SEO Analysis ──────────────────────────────────────────────
/**
* Analyze a generated post for SEO quality.
* Returns a 0-100 score with individual check results.
*/
public function handle_seo_analyze() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('edit_posts')) {
wp_send_json_error('Unauthorized');
}
$post_id = intval($_POST['post_id'] ?? 0);
if (!$post_id || !get_post($post_id)) {
wp_send_json_error('Post not found');
}
$result = $this->seo_score_post($post_id);
wp_send_json_success($result);
}
public function handle_seo_analyze_batch() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('edit_posts')) {
wp_send_json_error('Unauthorized');
}
$post_ids = array_map('intval', (array) ($_POST['post_ids'] ?? array()));
$post_ids = array_filter($post_ids);
if (empty($post_ids) || count($post_ids) > 50) {
wp_send_json_error('Invalid post IDs (1-50 allowed)');
}
$results = array();
foreach ($post_ids as $pid) {
if (get_post($pid)) {
$results[$pid] = $this->seo_score_post($pid);
}
}
wp_send_json_success(array('results' => $results));
}
private function seo_score_post($post_id) {
$post = get_post($post_id);
$title = $post->post_title;
$content = $post->post_content;
$slug = $post->post_name;
$meta_desc = $this->get_meta_description($post_id);
$focus_kw = $this->get_focus_keyword($post_id);
$text = wp_strip_all_tags($content);
$word_count = str_word_count($text);
// Parse images
preg_match_all('/
]*>/i', $content, $img_matches);
$total_images = count($img_matches[0]);
$images_with_alt = 0;
foreach ($img_matches[0] as $img) {
if (preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img, $alt_m) && trim($alt_m[1]) !== '') {
$images_with_alt++;
}
}
// Parse links
preg_match_all('/]+href\s*=\s*["\']([^"\']+)["\']/i', $content, $link_matches);
$site_url = home_url();
$internal_links = 0;
if (!empty($link_matches[1])) {
foreach ($link_matches[1] as $href) {
if (strpos($href, $site_url) === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) {
$internal_links++;
}
}
}
// Parse headings
preg_match_all('/]*>/i', $content, $h_matches);
$heading_count = count($h_matches[0]);
$has_subheadings = false;
foreach ($h_matches[1] as $lvl) {
if ($lvl >= 2) { $has_subheadings = true; break; }
}
$checks = array();
$score = 100;
// 1. Title length
$tl = mb_strlen($title);
if ($tl === 0) $checks['title_length'] = array('status' => 'fail', 'label' => 'Page Title', 'detail' => 'Missing', 'penalty' => 20);
elseif ($tl < 30) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — too short (aim for 50–60)', 'penalty' => 5);
elseif ($tl > 70) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — may truncate in search', 'penalty' => 3);
else $checks['title_length'] = array('status' => 'pass', 'label' => 'Page Title', 'detail' => $tl . ' chars — good length', 'penalty' => 0);
// 2. Meta description
$ml = mb_strlen($meta_desc);
if ($ml === 0) $checks['meta_desc'] = array('status' => 'fail', 'label' => 'Meta Description', 'detail' => 'Missing — engines will auto-generate one', 'penalty' => 15);
elseif ($ml < 120) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — could be longer (150–160)', 'penalty' => 3);
elseif ($ml > 160) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — may truncate (150–160)', 'penalty' => 3);
else $checks['meta_desc'] = array('status' => 'pass', 'label' => 'Meta Description', 'detail' => $ml . ' chars — good length', 'penalty' => 0);
// 3. Focus keyword placement
if (empty($focus_kw)) {
$checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'Not set — helps guide optimization', 'penalty' => 5);
} else {
$places = array();
if (mb_stripos($title, $focus_kw) !== false) $places[] = 'title';
if (mb_stripos($meta_desc, $focus_kw) !== false) $places[] = 'meta';
if (mb_stripos($text, $focus_kw) !== false) $places[] = 'content';
if (stripos($slug, str_replace(' ', '-', strtolower($focus_kw))) !== false) $places[] = 'slug';
if (count($places) >= 3) $checks['focus_kw'] = array('status' => 'pass', 'label' => 'Focus Keyword', 'detail' => 'Found in ' . implode(', ', $places), 'penalty' => 0);
elseif (count($places) >= 1) {
$miss = array_diff(array('title', 'meta', 'content', 'slug'), $places);
$checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'In ' . implode(', ', $places) . ' — missing from ' . implode(', ', array_slice($miss, 0, 2)), 'penalty' => 5);
} else $checks['focus_kw'] = array('status' => 'fail', 'label' => 'Focus Keyword', 'detail' => '"' . esc_html($focus_kw) . '" not found in content', 'penalty' => 10);
}
// 4. Content depth
if ($word_count < 300) $checks['content_depth'] = array('status' => 'fail', 'label' => 'Content Depth', 'detail' => $word_count . ' words — thin (aim for 800+)', 'penalty' => 12);
elseif ($word_count < 600) $checks['content_depth'] = array('status' => 'warn', 'label' => 'Content Depth', 'detail' => $word_count . ' words — light (800+ ideal)', 'penalty' => 5);
else $checks['content_depth'] = array('status' => 'pass', 'label' => 'Content Depth', 'detail' => number_format($word_count) . ' words', 'penalty' => 0);
// 5. Heading structure
if ($heading_count === 0) $checks['headings'] = array('status' => 'fail', 'label' => 'Heading Structure', 'detail' => 'No headings — add H2s to organize content', 'penalty' => 10);
elseif (!$has_subheadings) $checks['headings'] = array('status' => 'warn', 'label' => 'Heading Structure', 'detail' => 'Missing subheadings (H2/H3)', 'penalty' => 5);
else $checks['headings'] = array('status' => 'pass', 'label' => 'Heading Structure', 'detail' => $heading_count . ' headings — well structured', 'penalty' => 0);
// 6. Image ALT text
if ($total_images === 0) {
$checks['img_alt'] = array('status' => 'warn', 'label' => 'Image ALT Text', 'detail' => 'No images found', 'penalty' => 2);
} else {
$missing = $total_images - $images_with_alt;
$checks['img_alt'] = $missing === 0
? array('status' => 'pass', 'label' => 'Image ALT Text', 'detail' => 'All ' . $total_images . ' images have ALT text', 'penalty' => 0)
: array('status' => 'fail', 'label' => 'Image ALT Text', 'detail' => $missing . '/' . $total_images . ' missing ALT text', 'penalty' => min($missing * 3, 12));
}
// 7. Internal links
if ($internal_links === 0 && $word_count >= 300)
$checks['internal_links'] = array('status' => 'fail', 'label' => 'Internal Links', 'detail' => 'None — link to related content', 'penalty' => 8);
else
$checks['internal_links'] = array('status' => 'pass', 'label' => 'Internal Links', 'detail' => $internal_links ? $internal_links . ' found' : 'Short content — optional', 'penalty' => 0);
// 8. Slug quality
$sw = count(explode('-', $slug));
if (strlen($slug) > 75) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => 'Too long — shorten to 3–5 words', 'penalty' => 3);
elseif ($sw > 8) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => $sw . ' words — keep to 3–5', 'penalty' => 2);
else $checks['slug'] = array('status' => 'pass', 'label' => 'URL Slug', 'detail' => '/' . esc_html($slug), 'penalty' => 0);
// 9. Readability (Flesch-Kincaid)
$sents = max(count(preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY)), 1);
$syls = $this->seo_count_syllables($text);
$fk = max(0, min(100, round(206.835 - 1.015 * ($word_count / $sents) - 84.6 * ($syls / max($word_count, 1)))));
if ($fk >= 60) $checks['readability'] = array('status' => 'pass', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — easy to read', 'penalty' => 0);
elseif ($fk >= 40) $checks['readability'] = array('status' => 'warn', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — somewhat complex', 'penalty' => 3);
else $checks['readability'] = array('status' => 'fail', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — hard to read, simplify', 'penalty' => 7);
// 10. Featured image
$checks['featured_img'] = has_post_thumbnail($post_id)
? array('status' => 'pass', 'label' => 'Featured Image', 'detail' => 'Set', 'penalty' => 0)
: array('status' => 'warn', 'label' => 'Featured Image', 'detail' => 'Missing — important for social sharing', 'penalty' => 4);
// Calculate score
foreach ($checks as $c) { $score -= $c['penalty']; }
$score = max(0, min(100, $score));
$pass = $warn = $fail = 0;
foreach ($checks as $c) {
if ($c['status'] === 'pass') $pass++;
elseif ($c['status'] === 'warn') $warn++;
else $fail++;
}
// Cache results to post meta for the SEO dashboard list view
update_post_meta($post_id, '_mxchat_seo_score', $score);
update_post_meta($post_id, '_mxchat_seo_checks', $checks);
update_post_meta($post_id, '_mxchat_seo_analyzed', time());
return array(
'score' => $score,
'checks' => $checks,
'summary' => array('pass' => $pass, 'warn' => $warn, 'fail' => $fail),
);
}
/**
* AI-powered SEO suggestion for a specific field.
*/
public function handle_seo_suggest() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('edit_posts')) { wp_send_json_error('Unauthorized'); }
$post_id = intval($_POST['post_id'] ?? 0);
$field = sanitize_text_field($_POST['field'] ?? '');
if (!$post_id || !$field || !($post = get_post($post_id))) {
wp_send_json_error('Missing parameters');
}
$title = $post->post_title;
$content = wp_strip_all_tags($post->post_content);
$focus_kw = $this->get_focus_keyword($post_id);
// Sample content to manage token usage
$sample = mb_strlen($content) > 1200
? mb_substr($content, 0, 800) . "\n...\n" . mb_substr($content, -400)
: $content;
$kw = !empty($focus_kw) ? ' Incorporate the focus keyword "' . $focus_kw . '" naturally.' : '';
$prompts = array(
'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,
'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,
'slug' => 'Generate an SEO-friendly URL slug. 3-5 lowercase words with hyphens, no stop words. Return ONLY the slug.' . $kw . "\n\nTitle: " . $title,
'excerpt' => 'Write a concise excerpt in 1-2 sentences, under 200 characters. Return ONLY the text.' . $kw . "\n\nTitle: " . $title . "\n\nContent:\n" . $sample,
'readability' => true, // Handled by Advanced Content Editor add-on
'internal_links' => true, // Handled by Advanced Content Editor add-on
'img_alt' => true, // Handled by Advanced Content Editor add-on
'featured_img' => true, // Handled by Advanced Content Editor add-on
);
if (!isset($prompts[$field])) { wp_send_json_error('Invalid field'); }
// These fields are handled by the Advanced Content Editor add-on
$addon_fields = array('readability', 'internal_links', 'img_alt', 'featured_img');
if (in_array($field, $addon_fields, true)) {
$feature_key = 'seo_' . $field;
$has_addon = apply_filters('mxchat_content_pro_feature', false, $feature_key);
if (!$has_addon) {
wp_send_json_error('This feature requires the Advanced Content Editor add-on.');
return;
}
// Delegate to add-on via action hook
do_action('mxchat_seo_optimize_' . $field, $post_id, $post, $focus_kw);
return;
}
$response = $this->call_content_model(
'You are an expert SEO copywriter. Return only what is asked for. No quotes, no explanations, no prefixes.',
array(array('role' => 'user', 'content' => $prompts[$field])),
256
);
if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); }
$suggestion = trim($response);
// Save suggestion
if ($field === 'meta_description') {
$this->set_meta_description($post_id, $suggestion);
} elseif ($field === 'seo_title') {
wp_update_post(array('ID' => $post_id, 'post_title' => sanitize_text_field($suggestion)));
} elseif ($field === 'slug') {
wp_update_post(array('ID' => $post_id, 'post_name' => sanitize_title($suggestion)));
} elseif ($field === 'excerpt') {
wp_update_post(array('ID' => $post_id, 'post_excerpt' => sanitize_text_field($suggestion)));
}
wp_send_json_success(array('field' => $field, 'suggestion' => $suggestion));
}
/**
* List published posts/pages with cached SEO scores for the dashboard.
*/
public function handle_seo_list_posts() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
$page = max(1, intval($_POST['page'] ?? 1));
$per_page = 50;
$post_type = sanitize_text_field($_POST['post_type'] ?? 'any');
$filter = sanitize_text_field($_POST['filter'] ?? 'all');
$search = sanitize_text_field($_POST['search'] ?? '');
$sort_by = sanitize_text_field($_POST['sort_by'] ?? 'date');
$sort_order = strtoupper(sanitize_text_field($_POST['sort_order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
// Map sort_by to WP_Query orderby
$orderby = 'date';
$sort_meta_key = '';
switch ($sort_by) {
case 'title':
$orderby = 'title';
break;
case 'score':
$orderby = 'meta_value_num';
$sort_meta_key = '_mxchat_seo_score';
break;
case 'clicks':
$orderby = 'meta_value_num';
$sort_meta_key = '_mxchat_gsc_clicks';
break;
case 'impressions':
$orderby = 'meta_value_num';
$sort_meta_key = '_mxchat_gsc_impressions';
break;
default:
$orderby = 'date';
break;
}
$args = array(
'post_status' => 'publish',
'posts_per_page' => -1,
'post_type' => $post_type === 'any' ? array_values(array_diff(get_post_types(array('public' => true), 'names'), array('attachment'))) : $post_type,
'orderby' => $orderby,
'order' => $sort_order,
'fields' => 'ids',
);
if (!empty($search)) {
$args['s'] = $search;
}
// Meta query for score-based filters
if ($filter === 'issues') {
$args['meta_query'] = array(
array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '<', 'type' => 'NUMERIC'),
);
} elseif ($filter === 'good') {
$args['meta_query'] = array(
array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '>=', 'type' => 'NUMERIC'),
);
} elseif ($filter === 'unscored') {
$args['meta_query'] = array(
array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
);
}
// When sorting by a meta field, posts WITHOUT the key must stay in the
// list — GSC meta is no longer zero-filled for no-data posts (plan
// 282c80), and score meta never existed for unanalyzed posts. A single
// OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an
// OR relation WP_Meta_Query leaves the orderby clause's join alias
// unconstrained by meta_key, so ORDER BY reads arbitrary meta rows).
// Instead: two id-only queries — posts WITH the key sorted by its
// value, then posts WITHOUT it appended (newest first). Missing-data
// rows deliberately sort last in BOTH directions.
if ($sort_meta_key) {
$with_args = $args;
$with_args['meta_key'] = $sort_meta_key;
$with_args['orderby'] = 'meta_value_num';
$q_with = new \WP_Query($with_args);
$without_args = $args;
$missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS');
if (!empty($without_args['meta_query'])) {
$without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause);
} else {
$without_args['meta_query'] = array($missing_clause);
}
$without_args['orderby'] = 'date';
$without_args['order'] = 'DESC';
$q_without = new \WP_Query($without_args);
$all_ids = array_merge($q_with->posts, $q_without->posts);
} else {
$query = new \WP_Query($args);
$all_ids = $query->posts;
}
$total = count($all_ids);
$pages = max(1, ceil($total / $per_page));
$page = min($page, $pages);
$offset = ($page - 1) * $per_page;
$page_ids = array_slice($all_ids, $offset, $per_page);
$posts = array();
foreach ($page_ids as $pid) {
$p = get_post($pid);
$score = get_post_meta($pid, '_mxchat_seo_score', true);
$gsc_clicks = get_post_meta($pid, '_mxchat_gsc_clicks', true);
$gsc_impr = get_post_meta($pid, '_mxchat_gsc_impressions', true);
$posts[] = array(
'id' => $pid,
'title' => $p->post_title,
'type' => $p->post_type,
'date' => get_the_date('M j, Y', $pid),
'edit_url' => get_edit_post_link($pid, 'raw'),
'permalink' => get_permalink($pid),
'score' => $score !== '' ? intval($score) : null,
'analyzed' => (bool) get_post_meta($pid, '_mxchat_seo_analyzed', true),
'clicks' => $gsc_clicks !== '' ? intval($gsc_clicks) : null,
'impressions' => $gsc_impr !== '' ? intval($gsc_impr) : null,
);
}
// Count unscored for the Scan button
$unscored_q = new \WP_Query(array(
'post_status' => 'publish',
'posts_per_page' => -1,
'post_type' => array('post', 'page'),
'fields' => 'ids',
'meta_query' => array(
array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
),
));
$unscored_count = count($unscored_q->posts);
wp_send_json_success(array(
'posts' => $posts,
'page' => $page,
'pages' => $pages,
'total' => $total,
'unscored_count' => $unscored_count,
));
}
/**
* Count syllables in text (Flesch-Kincaid helper).
*/
private function seo_count_syllables($text) {
$words = preg_split('/\s+/', strtolower($text), -1, PREG_SPLIT_NO_EMPTY);
$total = 0;
foreach ($words as $w) {
$w = preg_replace('/[^a-z]/', '', $w);
if (strlen($w) <= 3) { $total++; continue; }
$w = preg_replace('/(?:[^laeiouy]es|ed|[^laeiouy]e)$/', '', $w);
preg_match_all('/[aeiouy]{1,2}/', $w, $m);
$total += max(1, count($m[0]));
}
return $total;
}
}