| @@ -731,10 +731,21 @@ | ||
| 731 | 731 | */ |
| 732 | 732 | private function plan_content($prompt, $content_type) { |
| 733 | 733 | $type_label = ($content_type === 'page') ? 'landing page' : 'blog post'; |
| 734 | 734 | |
| 735 | - $system_prompt = "You are a professional content strategist. The user wants to create a {$type_label}. Analyze their request and create a detailed content plan.\n\nYou MUST respond with ONLY valid JSON (no markdown, no code fences, no commentary). Use this exact structure:\n\n{\"title\": \"SEO-optimized title\", \"slug\": \"url-friendly-slug\", \"meta_description\": \"155 character meta description for SEO\", \"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"], \"links\": [{\"label\": \"Button or link text\", \"url\": \"https://example.com/page\"}], \"sections\": [{\"type\": \"hero\", \"heading\": \"Main heading\", \"subheading\": \"Supporting text\", \"needs_image\": true, \"image_prompt\": \"Detailed prompt for hero image\"}, {\"type\": \"content\", \"heading\": \"Section heading\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": true, \"image_prompt\": \"Detailed prompt for section image\"}, {\"type\": \"content\", \"heading\": \"Another section\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": false, \"image_prompt\": \"\"}, {\"type\": \"cta\", \"heading\": \"Call to action heading\", \"subheading\": \"CTA supporting text\", \"needs_image\": false, \"image_prompt\": \"\"}]}\n\nGuidelines:\n- Create 5-9 sections for blog posts, 7-11 for landing pages — add more if the user's request warrants extensive coverage\n- For landing pages, draw from sections like: hero, problem/solution, features, benefits, how-it-works, use cases, testimonials/social proof, pricing or comparison, FAQ, and final CTA — pick whichever fit the request\n- Each content section should include 3-5 substantive key_points (full descriptive sentences, not one-word labels) so the HTML generator has enough material to write meaningful copy\n- Include 2-4 sections that need images\n- Image prompts should be detailed, descriptive, and suitable for AI image generation\n- Image prompts should describe photorealistic or illustrative images relevant to the content\n- Keywords should be relevant long-tail SEO keywords\n- Title should be compelling and SEO-friendly\n- IMPORTANT: If the user specifies any URLs or links (for buttons, CTAs, navigation, etc.), you MUST capture every one of them in the \"links\" array with its label and exact URL. If no URLs are mentioned, use an empty array []."; | |
| 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 | + } | |
| 736 | 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 | + | |
| 737 | 748 | $messages = array( |
| 738 | 749 | array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"), |
| 739 | 750 | ); |
| 740 | 751 | |
| @@ -788,8 +799,15 @@ | ||
| 788 | 799 | ); |
| 789 | 800 | } |
| 790 | 801 | } |
| 791 | 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 | + | |
| 792 | 810 | if (empty($image_sections)) { |
| 793 | 811 | return array(); |
| 794 | 812 | } |
| 795 | 813 | |
| @@ -2224,11 +2242,25 @@ | ||
| 2224 | 2242 | |
| 2225 | 2243 | /** |
| 2226 | 2244 | * Call the configured content model |
| 2227 | 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 | + | |
| 2228 | 2260 | private function call_content_model($system_prompt, $messages, $max_tokens = 4096) { |
| 2229 | 2261 | $options = get_option('mxchat_options', array()); |
| 2230 | - $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest'; | |
| 2262 | + $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol'; | |
| 2231 | 2263 | |
| 2232 | 2264 | // Determine provider from model name |
| 2233 | 2265 | if ($this->is_claude_model($model)) { |
| 2234 | 2266 | return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens); |
| @@ -2246,8 +2278,24 @@ | ||
| 2246 | 2278 | |
| 2247 | 2279 | /** |
| 2248 | 2280 | * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek) |
| 2249 | 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 | + | |
| 2250 | 2298 | private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) { |
| 2251 | 2299 | if (empty($api_key)) { |
| 2252 | 2300 | return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat')); |
| 2253 | 2301 | } |
| @@ -2260,11 +2308,17 @@ | ||
| 2260 | 2308 | 'content' => $msg['content'] ?? '', |
| 2261 | 2309 | ); |
| 2262 | 2310 | } |
| 2263 | 2311 | |
| 2264 | - // GPT-5.x models require max_completion_tokens and only support temperature=1 | |
| 2265 | - $is_gpt5 = strpos($model, 'gpt-5') === 0; | |
| 2266 | - $token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens'; | |
| 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'); | |
| 2267 | 2321 | |
| 2268 | 2322 | $body = array( |
| 2269 | 2323 | 'model' => $model, |
| 2270 | 2324 | 'messages' => $formatted, |
| @@ -2271,27 +2325,30 @@ | ||
| 2271 | 2325 | $token_key => $max_tokens, |
| 2272 | 2326 | 'stream' => false, |
| 2273 | 2327 | ); |
| 2274 | 2328 | |
| 2275 | - if (!$is_gpt5) { | |
| 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) { | |
| 2276 | 2333 | $body['temperature'] = 0.7; |
| 2277 | 2334 | } |
| 2278 | 2335 | |
| 2279 | - // Add reasoning_effort only for GPT-5 models that support it | |
| 2280 | - // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter | |
| 2281 | - if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') { | |
| 2282 | - // GPT-5.1 uses 'low' instead of 'minimal' | |
| 2283 | - if ($model === 'gpt-5.1-2025-11-13') { | |
| 2284 | - $body['reasoning_effort'] = 'low'; | |
| 2285 | - } elseif ($model === 'gpt-5.5') { | |
| 2286 | - $body['reasoning_effort'] = 'low'; | |
| 2287 | - } elseif ($model === 'gpt-5.4') { | |
| 2288 | - $body['reasoning_effort'] = 'low'; | |
| 2289 | - } else { | |
| 2290 | - $body['reasoning_effort'] = 'minimal'; | |
| 2291 | - } | |
| 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; | |
| 2292 | 2341 | } |
| 2293 | 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 | + | |
| 2294 | 2351 | // Scale timeout with token count — large generation calls need more time |
| 2295 | 2352 | $timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2296 | 2353 | |
| 2297 | 2354 | $response = wp_remote_post($endpoint, array( |
| @@ -2309,8 +2366,38 @@ | ||
| 2309 | 2366 | |
| 2310 | 2367 | $status_code = wp_remote_retrieve_response_code($response); |
| 2311 | 2368 | $decoded = json_decode(wp_remote_retrieve_body($response), true); |
| 2312 | 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 | + | |
| 2313 | 2400 | if ($status_code !== 200) { |
| 2314 | 2401 | $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code; |
| 2315 | 2402 | return new WP_Error('api_error', $error_msg); |
| 2316 | 2403 | } |
| @@ -2325,8 +2412,12 @@ | ||
| 2325 | 2412 | /** |
| 2326 | 2413 | * Call Claude (Anthropic) API |
| 2327 | 2414 | */ |
| 2328 | 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'; } | |
| 2329 | 2420 | if (empty($api_key)) { |
| 2330 | 2421 | return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat')); |
| 2331 | 2422 | } |
| 2332 | 2423 | |
| @@ -2346,11 +2437,25 @@ | ||
| 2346 | 2437 | 'model' => $model, |
| 2347 | 2438 | 'max_tokens' => $max_tokens, |
| 2348 | 2439 | 'temperature' => 0.7, |
| 2349 | 2440 | 'messages' => $formatted, |
| 2350 | - 'system' => $system_prompt, | |
| 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 | + ), | |
| 2351 | 2447 | ); |
| 2352 | 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 | + | |
| 2353 | 2458 | $timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2354 | 2459 | |
| 2355 | 2460 | $response = wp_remote_post('https://api.anthropic.com/v1/messages', array( |
| 2356 | 2461 | 'headers' => array( |
| @@ -2373,10 +2478,16 @@ | ||
| 2373 | 2478 | $error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code; |
| 2374 | 2479 | return new WP_Error('claude_error', $error_msg); |
| 2375 | 2480 | } |
| 2376 | 2481 | |
| 2377 | - if (isset($decoded['content'][0]['text'])) { | |
| 2378 | - return trim($decoded['content'][0]['text']); | |
| 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 | + } | |
| 2379 | 2490 | } |
| 2380 | 2491 | |
| 2381 | 2492 | return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat')); |
| 2382 | 2493 | } |
| @@ -2385,8 +2496,11 @@ | ||
| 2385 | 2496 | /** |
| 2386 | 2497 | * Call Gemini API |
| 2387 | 2498 | */ |
| 2388 | 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 | + } | |
| 2389 | 2503 | if (empty($api_key)) { |
| 2390 | 2504 | return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat')); |
| 2391 | 2505 | } |
| 2392 | 2506 | |
| @@ -2483,9 +2597,9 @@ | ||
| 2483 | 2597 | |
| 2484 | 2598 | $field = sanitize_text_field($_POST['field'] ?? ''); |
| 2485 | 2599 | $value = sanitize_text_field($_POST['value'] ?? ''); |
| 2486 | 2600 | |
| 2487 | - $allowed_fields = array('content_model', 'content_image_model', 'content_image_quality', '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'); | |
| 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'); | |
| 2488 | 2602 | if (!in_array($field, $allowed_fields, true)) { |
| 2489 | 2603 | wp_send_json_error(array('message' => __('Invalid field.', 'mxchat'))); |
| 2490 | 2604 | } |
| 2491 | 2605 | |
| @@ -2498,8 +2612,13 @@ | ||
| 2498 | 2612 | if ($field === 'content_image_quality') { |
| 2499 | 2613 | $value = in_array($value, array('auto', 'low', 'medium', 'high'), true) ? $value : 'auto'; |
| 2500 | 2614 | } |
| 2501 | 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 | + | |
| 2502 | 2621 | $options = get_option('mxchat_options', array()); |
| 2503 | 2622 | $options[$field] = $value; |
| 2504 | 2623 | update_option('mxchat_options', $options); |
| 2505 | 2624 | |
| @@ -3241,23 +3360,39 @@ | ||
| 3241 | 3360 | array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'), |
| 3242 | 3361 | ); |
| 3243 | 3362 | } |
| 3244 | 3363 | |
| 3245 | - // When sorting by a meta field, ensure meta_key is set for ordering. | |
| 3246 | - // For 'all' filter, include posts without the meta key via OR clause. | |
| 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. | |
| 3247 | 3373 | if ($sort_meta_key) { |
| 3248 | - $args['meta_key'] = $sort_meta_key; | |
| 3249 | - if ($filter === 'all') { | |
| 3250 | - $args['meta_query'] = array( | |
| 3251 | - 'relation' => 'OR', | |
| 3252 | - array('key' => $sort_meta_key, 'compare' => 'EXISTS'), | |
| 3253 | - array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'), | |
| 3254 | - ); | |
| 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); | |
| 3255 | 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; | |
| 3256 | 3394 | } |
| 3257 | - | |
| 3258 | - $query = new \WP_Query($args); | |
| 3259 | - $all_ids = $query->posts; | |
| 3260 | 3395 | $total = count($all_ids); |
| 3261 | 3396 | $pages = max(1, ceil($total / $per_page)); |
| 3262 | 3397 | $page = min($page, $pages); |
| 3263 | 3398 | $offset = ($page - 1) * $per_page; |