PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.83
51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 All 39 releases
← All changes | includes/Admin.php +1285 -351 51.1.3551.1.83 View file →
@@ -5,8 +5,10 @@
5 5 */
6 6
7 7 namespace King_Addons;
8 8
9 +use King_Addons\Wishlist\Wishlist_Settings;
10 +
9 11 if (!defined('ABSPATH')) {
10 12 exit; // Exit if accessed directly.
11 13 }
12 14
@@ -14,33 +16,63 @@
14 16 {
15 17 public function __construct()
16 18 {
17 19 if (is_admin()) {
18 - add_action('admin_menu', [$this, 'addAdminMenu']);
20 + // Use priority 5 to ensure the main menu is created BEFORE feature submenus (which use priority 10 or higher)
21 + add_action('admin_menu', [$this, 'addAdminMenu'], 5);
19 22
20 23 // Always add the action, but check conditions inside addUpgradeMenu
21 - add_action('admin_menu', [$this, 'addUpgradeMenu'], 999999999); // Highest priority to add at the very end
24 + add_action('admin_menu', [$this, 'addUpgradeMenu'], 9999999999); // Highest priority to add at the very end
22 25
26 + // Reorder submenu items after ALL entries are registered.
27 + add_action('admin_menu', [$this, 'reorderKingAddonsSubmenu'], 1000000000);
28 + add_action('admin_menu', [$this, 'reorderWooBuilderSubmenu'], 1000000000);
29 +
23 30 add_action('admin_init', [$this, 'createSettings']);
24 31 add_action('admin_init', [$this, 'createAiSettings']);
32 +
33 + // Only register wishlist settings when the Wishlist extension is enabled.
34 + $options = get_option('king_addons_options', []);
35 + $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
36 + && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
37 + if ($wishlist_enabled) {
38 + add_action('admin_init', [$this, 'createWishlistSettings']);
39 + }
25 40 add_action('admin_enqueue_scripts', [$this, 'enqueueUpgradeLinkScript']);
41 + add_action('admin_enqueue_scripts', [$this, 'enqueueGlobalAdminStyles']);
42 +
43 + // AJAX handler for Trinity Backup plugin installation
44 + add_action('wp_ajax_king_addons_install_trinity_backup', [$this, 'handleInstallTrinityBackup']);
26 45 }
27 46 }
28 47
29 48 function addAdminMenu(): void
30 49 {
50 + global $menu;
51 + $menu['54.0'] = array( '', 'read', 'separator-king-addons-top', '', 'wp-menu-separator elementor' );
52 +
31 53 add_menu_page(
32 54 'King Addons for Elementor',
33 55 'King Addons',
34 56 'manage_options',
35 57 'king-addons',
36 - [$this, 'showAdminPage'],
58 + [$this, 'showDashboardV3'],
37 59 KING_ADDONS_URL . 'includes/admin/img/icon-for-admin.svg',
38 - 58.7
60 + 54.1
39 61 );
40 62
63 + // Ensure the first submenu item is labeled "Dashboard" (instead of repeating "King Addons").
41 64 add_submenu_page(
42 65 'king-addons',
66 + esc_html__('Dashboard', 'king-addons'),
67 + esc_html__('Dashboard', 'king-addons'),
68 + 'manage_options',
69 + 'king-addons',
70 + [$this, 'showDashboardV3']
71 + );
72 +
73 + add_submenu_page(
74 + 'king-addons',
43 75 'King Addons Settings',
44 76 'Settings',
45 77 'manage_options',
46 78 'king-addons-settings',
@@ -46,11 +78,91 @@
46 78 'king-addons-settings',
47 79 [$this, 'showSettingsPage']
48 80 );
49 81
50 - if (KING_ADDONS_WGT_FORM_BUILDER) {
82 + // Add AI Settings submenu under King Addons (kept near Settings; final ordering is enforced later).
83 + add_submenu_page(
84 + 'king-addons',
85 + esc_html__('AI Settings', 'king-addons'),
86 + esc_html__('AI Settings', 'king-addons'),
87 + 'manage_options',
88 + 'king-addons-ai-settings',
89 + [$this, 'showAiSettingsPage']
90 + );
91 +
92 + // Get options for extension toggle checks (before any extension checks)
93 + $options = get_option('king_addons_options', []);
94 +
95 + // Check Cookie / Consent Bar extension toggle
96 + $cookie_consent_enabled = !isset($options['ext_cookie-consent']) || $options['ext_cookie-consent'] === 'enabled';
97 + if ($cookie_consent_enabled && (defined('KING_ADDONS_EXT_COOKIE_CONSENT') ? KING_ADDONS_EXT_COOKIE_CONSENT : true) && class_exists('King_Addons\Cookie_Consent')) {
51 98 add_submenu_page(
52 99 'king-addons',
100 + esc_html__('Cookie / Consent Bar', 'king-addons'),
101 + esc_html__('Cookie / Consent Bar', 'king-addons'),
102 + 'manage_options',
103 + 'king-addons-cookie-consent',
104 + [Cookie_Consent::instance(), 'render_admin_page']
105 + );
106 + }
107 +
108 + // Check Age Gate extension toggle
109 + $age_gate_enabled = !isset($options['ext_age-gate']) || $options['ext_age-gate'] === 'enabled';
110 + if ($age_gate_enabled && (defined('KING_ADDONS_EXT_AGE_GATE') ? KING_ADDONS_EXT_AGE_GATE : true) && class_exists('King_Addons\Age_Gate')) {
111 + add_submenu_page(
112 + 'king-addons',
113 + esc_html__('Age Gate', 'king-addons'),
114 + esc_html__('Age Gate', 'king-addons'),
115 + 'manage_options',
116 + 'king-addons-age-gate',
117 + [Age_Gate::instance(), 'render_admin_page']
118 + );
119 + }
120 +
121 + // Check Live Chat extension toggle
122 + $live_chat_enabled = !isset($options['ext_live-chat']) || $options['ext_live-chat'] === 'enabled';
123 + if ($live_chat_enabled && (defined('KING_ADDONS_EXT_LIVE_CHAT') ? KING_ADDONS_EXT_LIVE_CHAT : true) && class_exists('King_Addons\Live_Chat')) {
124 + add_submenu_page(
125 + 'king-addons',
126 + esc_html__('Live Chat', 'king-addons'),
127 + esc_html__('Live Chat', 'king-addons'),
128 + 'manage_options',
129 + 'king-addons-live-chat',
130 + [Live_Chat::instance(), 'render_admin_page']
131 + );
132 + }
133 +
134 + // Check Docs & KB extension toggle
135 + $docs_kb_enabled = !isset($options['ext_docs-kb']) || $options['ext_docs-kb'] === 'enabled';
136 + if ($docs_kb_enabled && (defined('KING_ADDONS_EXT_DOCS_KB') ? KING_ADDONS_EXT_DOCS_KB : true) && class_exists('King_Addons\Docs_KB')) {
137 + add_submenu_page(
138 + 'king-addons',
139 + esc_html__('Docs & Knowledge Base', 'king-addons'),
140 + esc_html__('Docs & Knowledge Base Builder', 'king-addons'),
141 + 'manage_options',
142 + 'king-addons-docs-kb',
143 + [Docs_KB::instance(), 'render_admin_page']
144 + );
145 + }
146 +
147 + // Check Activity Log extension toggle
148 + $activity_log_enabled = !isset($options['ext_activity-log']) || $options['ext_activity-log'] === 'enabled';
149 + if ($activity_log_enabled && class_exists('King_Addons\\Activity_Log')) {
150 + add_submenu_page(
151 + 'king-addons',
152 + esc_html__('Activity Log', 'king-addons'),
153 + esc_html__('Activity Log', 'king-addons'),
154 + 'manage_options',
155 + 'king-addons-activity-log',
156 + [Activity_Log::instance(), 'render_admin_page']
157 + );
158 + }
159 +
160 + // Check if Form Builder widget is enabled
161 + $form_builder_enabled = !isset($options['form-builder']) || $options['form-builder'] === 'enabled';
162 + if (KING_ADDONS_WGT_FORM_BUILDER && $form_builder_enabled) {
163 + add_submenu_page(
164 + 'king-addons',
53 165 esc_html__('Form Submissions', 'king-addons'),
54 166 esc_html__('Form Submissions', 'king-addons'),
55 167 'edit_posts',
56 168 'edit.php?post_type=king-addons-fb-sub',
@@ -56,9 +168,11 @@
56 168 'edit.php?post_type=king-addons-fb-sub',
57 169 );
58 170 }
59 171
60 - if (KING_ADDONS_EXT_TEMPLATES_CATALOG) {
172 + // Check Templates Catalog extension toggle
173 + $templates_enabled = !isset($options['ext_templates-catalog']) || $options['ext_templates-catalog'] === 'enabled';
174 + if ($templates_enabled && (defined('KING_ADDONS_EXT_TEMPLATES_CATALOG') ? KING_ADDONS_EXT_TEMPLATES_CATALOG : true) && class_exists('King_Addons\Templates')) {
61 175 add_menu_page(
62 176 'King Addons for Elementor',
63 177 (!king_addons_freemius()->can_use_premium_code() ? esc_html__('Free Templates', 'king-addons') : esc_html__('Templates Pro', 'king-addons')),
64 178 'manage_options',
@@ -63,44 +177,182 @@
63 177 (!king_addons_freemius()->can_use_premium_code() ? esc_html__('Free Templates', 'king-addons') : esc_html__('Templates Pro', 'king-addons')),
64 178 'manage_options',
65 179 'king-addons-templates',
66 180 [Templates::instance(), 'render_template_catalog_page'],
67 - KING_ADDONS_URL . 'includes/admin/img/icon-for-menu-templates.svg',
68 - 58.71
181 + KING_ADDONS_URL . (!king_addons_freemius()->can_use_premium_code() ? 'includes/admin/img/icon-for-menu-templates.svg' : 'includes/admin/img/icon-for-menu-templates-v2.svg'),
182 + 54.2
69 183 );
70 184 }
71 185
72 - if (KING_ADDONS_EXT_HEADER_FOOTER_BUILDER) {
186 + // Check Header & Footer Builder extension toggle
187 + $header_footer_enabled = !isset($options['ext_header-footer-builder']) || $options['ext_header-footer-builder'] === 'enabled';
188 + if ($header_footer_enabled && (defined('KING_ADDONS_EXT_HEADER_FOOTER_BUILDER') ? KING_ADDONS_EXT_HEADER_FOOTER_BUILDER : true) && class_exists('King_Addons\Header_Footer_Builder')) {
73 189 self::showHeaderFooterBuilder();
74 190 }
75 191
76 - if (KING_ADDONS_EXT_POPUP_BUILDER) {
192 + // Check Popup Builder extension toggle
193 + $popup_builder_enabled = !isset($options['ext_popup-builder']) || $options['ext_popup-builder'] === 'enabled';
194 + if ($popup_builder_enabled && (defined('KING_ADDONS_EXT_POPUP_BUILDER') ? KING_ADDONS_EXT_POPUP_BUILDER : true) && class_exists('King_Addons\Popup_Builder')) {
77 195 self::showPopupBuilder();
78 196 }
79 197
80 - // Add AI Settings submenu under King Addons
81 - add_submenu_page(
82 - 'king-addons',
83 - esc_html__('AI Settings', 'king-addons'),
84 - esc_html__('AI Settings', 'king-addons'),
85 - 'manage_options',
86 - 'king-addons-ai-settings',
87 - [$this, 'showAiSettingsPage']
88 - );
198 + // WooCommerce Builder is a top-level menu. Store screens hang off it
199 + // when it exists; otherwise they stay under King Addons. Page slugs
200 + // do not change.
201 + if (king_addons_woo_builder_menu_is_active()) {
202 + $this->showWooBuilder();
203 + }
204 +
205 + $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
206 + && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
207 + if ($wishlist_enabled) {
208 + $woo_parent = king_addons_woo_admin_parent_slug();
209 + add_submenu_page(
210 + $woo_parent,
211 + esc_html__('Wishlist', 'king-addons'),
212 + esc_html__('Wishlist', 'king-addons'),
213 + 'manage_options',
214 + 'king-addons-wishlist',
215 + [$this, 'renderWishlistPage']
216 + );
217 +
218 + add_submenu_page(
219 + $woo_parent,
220 + esc_html__('Wishlist Analytics', 'king-addons'),
221 + esc_html__('Wishlist Analytics', 'king-addons'),
222 + 'manage_options',
223 + 'king-addons-wishlist-analytics',
224 + [$this, 'renderWishlistAnalyticsPage']
225 + );
226 + }
227 +
228 + $menu['54.8'] = array( '', 'read', 'separator-king-addons-bottom', '', 'wp-menu-separator elementor' );
229 +
89 230 }
90 231
232 + /**
233 + * Enforce submenu order for King Addons:
234 + * 1) Dashboard
235 + * 2) Settings
236 + * 3) AI Settings
237 + * 4) Account (Freemius)
238 + * 5) Contact Us (Freemius)
239 + * 6) Everything else alphabetically
240 + */
241 + public function reorderKingAddonsSubmenu(): void
242 + {
243 + global $submenu;
244 +
245 + if (!is_array($submenu) || empty($submenu['king-addons']) || !is_array($submenu['king-addons'])) {
246 + return;
247 + }
248 +
249 + $priorityBySlug = [
250 + 'king-addons' => 0,
251 + 'king-addons-settings' => 1,
252 + 'king-addons-ai-settings' => 2,
253 + 'king-addons-account' => 3,
254 + 'king-addons-contact' => 4,
255 + ];
256 +
257 + $items = $submenu['king-addons'];
258 +
259 + usort($items, static function ($a, $b) use ($priorityBySlug): int {
260 + $aSlug = isset($a[2]) ? (string) $a[2] : '';
261 + $bSlug = isset($b[2]) ? (string) $b[2] : '';
262 +
263 + $aPriority = array_key_exists($aSlug, $priorityBySlug) ? $priorityBySlug[$aSlug] : null;
264 + $bPriority = array_key_exists($bSlug, $priorityBySlug) ? $priorityBySlug[$bSlug] : null;
265 +
266 + if ($aPriority !== null || $bPriority !== null) {
267 + $aPriority = $aPriority ?? 9999;
268 + $bPriority = $bPriority ?? 9999;
269 + if ($aPriority !== $bPriority) {
270 + return $aPriority <=> $bPriority;
271 + }
272 + }
273 +
274 + $aLabel = isset($a[0]) ? wp_strip_all_tags((string) $a[0]) : '';
275 + $bLabel = isset($b[0]) ? wp_strip_all_tags((string) $b[0]) : '';
276 +
277 + $cmp = strcasecmp($aLabel, $bLabel);
278 + if ($cmp !== 0) {
279 + return $cmp;
280 + }
281 +
282 + return strcasecmp($aSlug, $bSlug);
283 + });
284 +
285 + $submenu['king-addons'] = $items;
286 + }
287 +
288 + /**
289 + * WooCommerce Builder submenu:
290 + * 1) Dashboard (same slug as the top-level page)
291 + * 2) Wishlist
292 + * 3) Wishlist Analytics
293 + * 4) Free Shipping Bar
294 + * 5) Sticky Add To Cart
295 + * 6) My Account Endpoints
296 + */
297 + public function reorderWooBuilderSubmenu(): void
298 + {
299 + global $submenu;
300 +
301 + if (!is_array($submenu) || empty($submenu['king-addons-woo-builder']) || !is_array($submenu['king-addons-woo-builder'])) {
302 + return;
303 + }
304 +
305 + $priorityBySlug = [
306 + 'king-addons-woo-builder' => 0,
307 + 'king-addons-wishlist' => 1,
308 + 'king-addons-wishlist-analytics' => 2,
309 + 'king-addons-free-shipping-bar' => 3,
310 + 'king-addons-sticky-add-to-cart' => 4,
311 + 'king-addons-myaccount-endpoints' => 5,
312 + ];
313 +
314 + $items = $submenu['king-addons-woo-builder'];
315 +
316 + usort($items, static function ($a, $b) use ($priorityBySlug): int {
317 + $aSlug = isset($a[2]) ? (string) $a[2] : '';
318 + $bSlug = isset($b[2]) ? (string) $b[2] : '';
319 +
320 + $aPriority = array_key_exists($aSlug, $priorityBySlug) ? $priorityBySlug[$aSlug] : 9999;
321 + $bPriority = array_key_exists($bSlug, $priorityBySlug) ? $priorityBySlug[$bSlug] : 9999;
322 +
323 + if ($aPriority !== $bPriority) {
324 + return $aPriority <=> $bPriority;
325 + }
326 +
327 + $aLabel = isset($a[0]) ? wp_strip_all_tags((string) $a[0]) : '';
328 + $bLabel = isset($b[0]) ? wp_strip_all_tags((string) $b[0]) : '';
329 +
330 + $cmp = strcasecmp($aLabel, $bLabel);
331 + if ($cmp !== 0) {
332 + return $cmp;
333 + }
334 +
335 + return strcasecmp($aSlug, $bSlug);
336 + });
337 +
338 + $submenu['king-addons-woo-builder'] = $items;
339 + }
340 +
91 341 function addUpgradeMenu(): void
92 342 {
93 343 // Don't add menu if Freemius is showing opt-in/activation
94 344 $fs = king_addons_freemius();
95 -
345 +
96 346 // Check if we're on any Freemius-related page
97 - if (isset($_GET['fs_action']) ||
98 - $fs->is_activation_mode() ||
99 - (!$fs->is_registered() && !$fs->is_anonymous() && !$fs->is_tracking_prohibited())) {
347 + if (
348 + isset($_GET['fs_action']) ||
349 + $fs->is_activation_mode() ||
350 + (!$fs->is_registered() && !$fs->is_anonymous() && !$fs->is_tracking_prohibited())
351 + ) {
100 352 return;
101 353 }
102 -
354 +
103 355 // Add Upgrade submenu under King Addons (only if premium is not active)
104 356 if (!$fs->can_use_premium_code()) {
105 357 add_submenu_page(
106 358 'king-addons',
@@ -121,60 +373,238 @@
121 373 'manage_options',
122 374 'king-addons-popup-builder',
123 375 [Popup_Builder::instance(), 'renderPopupBuilder'],
124 376 KING_ADDONS_URL . 'includes/admin/img/icon-for-popup-builder.svg',
125 - 58.73
377 + 54.4
126 378 );
127 379 }
128 380
381 + /**
382 + * Register WooCommerce Builder menu item.
383 + *
384 + * @return void
385 + */
386 + public function showWooBuilder(): void
387 + {
388 + add_menu_page(
389 + esc_html__('WooCommerce Builder', 'king-addons'),
390 + esc_html__('WooCommerce Builder', 'king-addons'),
391 + 'manage_options',
392 + 'king-addons-woo-builder',
393 + [$this, 'renderWooBuilderPage'],
394 + 'dashicons-cart',
395 + 54.5
396 + );
397 +
398 + // Same slug as the parent so the first submenu item is "Dashboard"
399 + // instead of repeating "WooCommerce Builder".
400 + add_submenu_page(
401 + 'king-addons-woo-builder',
402 + esc_html__('WooCommerce Builder', 'king-addons'),
403 + esc_html__('Dashboard', 'king-addons'),
404 + 'manage_options',
405 + 'king-addons-woo-builder',
406 + [$this, 'renderWooBuilderPage']
407 + );
408 + }
409 +
410 + /**
411 + * Render WooCommerce Builder admin page.
412 + *
413 + * @return void
414 + */
415 + public function renderWooBuilderPage(): void
416 + {
417 + if (!current_user_can('manage_options')) {
418 + return;
419 + }
420 +
421 + require_once KING_ADDONS_PATH . 'includes/admin/layouts/woo-builder-page.php';
422 + }
423 +
129 424 function showHeaderFooterBuilder(): void
130 425 {
131 - $post_type = 'king-addons-el-hf';
132 - $menu_slug = 'edit.php?post_type=' . $post_type;
426 + $menu_slug = 'king-addons-el-hf';
427 + $callback = [\King_Addons\Header_Footer_Builder::instance(), 'renderAdminPage'];
133 428
134 - // Add Main Menu
429 + // Add Main Menu (new unified UI)
135 430 add_menu_page(
136 431 esc_html__('Elementor Header & Footer Builder', 'king-addons'),
137 432 esc_html__('Header & Footer', 'king-addons'),
138 433 'manage_options',
139 - $menu_slug, // Menu slug points to the custom post type edit screen
140 - '', // No callback function needed
434 + $menu_slug,
435 + $callback,
141 436 KING_ADDONS_URL . 'includes/admin/img/icon-for-header-footer-builder.svg',
142 - 58.72
437 + 54.3
143 438 );
144 439
145 - // Add 'All Templates' Submenu - this will be the first submenu item
440 + // Ensure the first submenu item is labeled "Templates"
146 441 add_submenu_page(
147 - $menu_slug, // Parent slug matches the main menu slug
148 - esc_html__('All Templates', 'king-addons'),
149 - esc_html__('All Templates', 'king-addons'),
150 - 'edit_posts',
151 - $menu_slug
442 + $menu_slug,
443 + esc_html__('Templates', 'king-addons'),
444 + esc_html__('Templates', 'king-addons'),
445 + 'manage_options',
446 + $menu_slug,
447 + $callback
152 448 );
449 +
450 + // Display Settings submenu (same page, different tab)
451 + add_submenu_page(
452 + $menu_slug,
453 + esc_html__('Display Settings', 'king-addons'),
454 + esc_html__('Display Settings', 'king-addons'),
455 + 'manage_options',
456 + $menu_slug . '&tab=settings',
457 + $callback
458 + );
153 459 }
154 460
155 - function showAdminPage(): void
461 + function showSettingsPage(): void
156 462 {
157 463 if (!current_user_can('manage_options')) {
158 464 return;
159 465 }
160 466
161 - self::enqueueAdminAssets();
467 + require_once(KING_ADDONS_PATH . 'includes/admin/layouts/settings-page.php');
162 468
163 - require_once(KING_ADDONS_PATH . 'includes/admin/layouts/admin-page.php');
469 + self::enqueueSettingsAssets();
164 470 }
165 471
166 - function showSettingsPage(): void
472 + function showDashboardV3(): void
167 473 {
168 474 if (!current_user_can('manage_options')) {
169 475 return;
170 476 }
171 477
172 - require_once(KING_ADDONS_PATH . 'includes/admin/layouts/settings-page.php');
478 + require_once(KING_ADDONS_PATH . 'includes/admin/layouts/dashboard-v3/dashboard-v3.php');
479 + }
173 480
481 + /**
482 + * Handle AJAX request to install/activate Trinity Backup plugin.
483 + *
484 + * @return void
485 + */
486 + public function handleInstallTrinityBackup(): void
487 + {
488 + check_ajax_referer('king_addons_install_plugin', 'nonce');
489 +
490 + if (!current_user_can('install_plugins')) {
491 + wp_send_json_error(esc_html__('Permission denied.', 'king-addons'));
492 + }
493 +
494 + $plugin_action = isset($_POST['plugin_action']) ? sanitize_text_field($_POST['plugin_action']) : '';
495 + $plugin_slug = 'trinity-backup';
496 + $plugin_file = 'trinity-backup/trinity-backup.php';
497 + $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file;
498 +
499 + // If action is activate and plugin exists, just activate it
500 + if ($plugin_action === 'activate') {
501 + if (file_exists($plugin_path)) {
502 + $result = activate_plugin($plugin_file);
503 + if (is_wp_error($result)) {
504 + wp_send_json_error($result->get_error_message());
505 + }
506 + wp_send_json_success(['message' => esc_html__('Plugin activated!', 'king-addons')]);
507 + } else {
508 + wp_send_json_error(esc_html__('Plugin not found.', 'king-addons'));
509 + }
510 + return;
511 + }
512 +
513 + // Install plugin
514 + require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
515 + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
516 + require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
517 +
518 + // Get plugin info from WordPress.org
519 + $api = plugins_api('plugin_information', [
520 + 'slug' => $plugin_slug,
521 + 'fields' => [
522 + 'short_description' => false,
523 + 'sections' => false,
524 + 'requires' => false,
525 + 'rating' => false,
526 + 'ratings' => false,
527 + 'downloaded' => false,
528 + 'last_updated' => false,
529 + 'added' => false,
530 + 'tags' => false,
531 + 'compatibility' => false,
532 + 'homepage' => false,
533 + 'donate_link' => false,
534 + ],
535 + ]);
536 +
537 + if (is_wp_error($api)) {
538 + wp_send_json_error($api->get_error_message());
539 + }
540 +
541 + $skin = new \WP_Ajax_Upgrader_Skin();
542 + $upgrader = new \Plugin_Upgrader($skin);
543 + $result = $upgrader->install($api->download_link);
544 +
545 + if (is_wp_error($result)) {
546 + wp_send_json_error($result->get_error_message());
547 + }
548 +
549 + if ($result === false) {
550 + wp_send_json_error(esc_html__('Installation failed.', 'king-addons'));
551 + }
552 +
553 + // Activate the plugin after installation
554 + $activate_result = activate_plugin($plugin_file);
555 + if (is_wp_error($activate_result)) {
556 + // Installed but not activated
557 + wp_send_json_success(['message' => esc_html__('Plugin installed! Please activate manually.', 'king-addons')]);
558 + }
559 +
560 + wp_send_json_success(['message' => esc_html__('Plugin installed and activated!', 'king-addons')]);
561 + }
562 +
563 + /**
564 + * Render Wishlist admin page.
565 + *
566 + * @return void
567 + */
568 + public function renderWishlistPage(): void
569 + {
570 + if (!current_user_can('manage_options')) {
571 + return;
572 + }
573 +
574 + $options = get_option('king_addons_options', []);
575 + $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
576 + && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
577 + if (!$wishlist_enabled || !class_exists(Wishlist_Settings::class)) {
578 + return;
579 + }
580 +
174 581 self::enqueueSettingsAssets();
582 + require_once KING_ADDONS_PATH . 'includes/admin/layouts/wishlist-page.php';
175 583 }
176 584
585 + /**
586 + * Render Wishlist analytics page.
587 + *
588 + * @return void
589 + */
590 + public function renderWishlistAnalyticsPage(): void
591 + {
592 + if (!current_user_can('manage_options')) {
593 + return;
594 + }
595 +
596 + $options = get_option('king_addons_options', []);
597 + $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
598 + && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
599 + if (!$wishlist_enabled || !class_exists(Wishlist_Settings::class)) {
600 + return;
601 + }
602 +
603 + self::enqueueSettingsAssets();
604 + require_once KING_ADDONS_PATH . 'includes/admin/layouts/wishlist-analytics.php';
605 + }
606 +
177 607 function createSettings(): void
178 608 {
179 609 // Register a new setting for "king-addons" page.
180 610 register_setting('king_addons', 'king_addons_options');
@@ -195,8 +625,14 @@
195 625 'king-addons'
196 626 );
197 627
198 628 foreach (ModulesMap::getModulesMapArray()['widgets'] as $widget_id => $widget_array) {
629 + // Hide widgets hard-disabled via constants (QA rollout).
630 + $widget_constant = 'KING_ADDONS_WGT_' . strtoupper(str_replace('-', '_', (string) $widget_id));
631 + if (defined($widget_constant) && constant($widget_constant) === false) {
632 + continue;
633 + }
634 +
199 635 add_settings_field(
200 636 $widget_id,
201 637 $widget_array['title'],
202 638 '',
@@ -212,8 +648,14 @@
212 648 );
213 649 }
214 650
215 651 foreach (ModulesMap::getModulesMapArray()['features'] as $feature_id => $feature_array) {
652 + // Hide features hard-disabled via constants (QA rollout).
653 + $feature_constant = 'KING_ADDONS_FEAT_' . strtoupper(str_replace('-', '_', (string) $feature_id));
654 + if (defined($feature_constant) && constant($feature_constant) === false) {
655 + continue;
656 + }
657 +
216 658 add_settings_field(
217 659 $feature_id,
218 660 $feature_array['title'],
219 661 '',
@@ -229,23 +671,88 @@
229 671 );
230 672 }
231 673 }
232 674
675 + /**
676 + * Register wishlist settings group.
677 + *
678 + * @return void
679 + */
680 + public function createWishlistSettings(): void
681 + {
682 + if (defined('KING_ADDONS_EXT_WISHLIST') && KING_ADDONS_EXT_WISHLIST === false) {
683 + return;
684 + }
685 +
686 + if (!class_exists(Wishlist_Settings::class)) {
687 + return;
688 + }
689 +
690 + register_setting(
691 + 'king_addons_wishlist',
692 + 'king_addons_wishlist_settings',
693 + [
694 + 'type' => 'array',
695 + 'sanitize_callback' => [$this, 'sanitizeWishlistSettings'],
696 + 'default' => Wishlist_Settings::defaults(),
697 + ]
698 + );
699 + }
700 +
701 + /**
702 + * Sanitize wishlist settings payload.
703 + *
704 + * @param array<string, mixed> $settings Raw settings.
705 + * @return array<string, mixed> Sanitized settings.
706 + */
707 + public function sanitizeWishlistSettings(array $settings): array
708 + {
709 + if (!class_exists(Wishlist_Settings::class)) {
710 + return [];
711 + }
712 +
713 + $defaults = Wishlist_Settings::defaults();
714 +
715 + $sanitized = [
716 + 'enabled' => !empty($settings['enabled']),
717 + 'wishlist_page_id' => absint($settings['wishlist_page_id'] ?? 0),
718 + 'allow_guests' => !empty($settings['allow_guests']),
719 + 'guest_block_text' => sanitize_text_field($settings['guest_block_text'] ?? $defaults['guest_block_text']),
720 + 'button_add_text' => sanitize_text_field($settings['button_add_text'] ?? $defaults['button_add_text']),
721 + 'button_added_text' => sanitize_text_field($settings['button_added_text'] ?? $defaults['button_added_text']),
722 + 'button_display_mode' => in_array($settings['button_display_mode'] ?? 'icon_text', ['icon', 'icon_text'], true) ? $settings['button_display_mode'] : $defaults['button_display_mode'],
723 + 'button_position' => in_array($settings['button_position'] ?? 'after_add_to_cart', ['before_add_to_cart', 'after_add_to_cart'], true) ? $settings['button_position'] : $defaults['button_position'],
724 + 'show_in_archives' => !empty($settings['show_in_archives']),
725 + 'show_on_single' => !empty($settings['show_on_single']),
726 + 'wishlist_columns' => array_values(
727 + array_intersect(
728 + (array) ($settings['wishlist_columns'] ?? []),
729 + ['image', 'title', 'price', 'stock', 'notes', 'add_to_cart', 'remove']
730 + )
731 + ),
732 + 'cache_enabled' => !empty($settings['cache_enabled']),
733 + 'cache_ttl' => max(0, absint($settings['cache_ttl'] ?? 0)),
734 + 'icon_choice' => sanitize_text_field($settings['icon_choice'] ?? $defaults['icon_choice']),
735 + ];
736 +
737 + return wp_parse_args($sanitized, $defaults);
738 + }
739 +
233 740 function king_addons_section_widgets_callback($args): void
234 741 {
235 -?>
236 - <h2 id="<?php echo esc_attr($args['id']); ?>"
237 - class="kng-section-title"><?php esc_html_e('Elements', 'king-addons'); ?></h2>
238 - <?php
742 + ?>
743 + <h2 id="<?php echo esc_attr($args['id']); ?>" class="kng-section-title"><?php esc_html_e('Elements', 'king-addons'); ?>
744 + </h2>
745 + <?php
239 746 }
240 747
241 748 function king_addons_section_features_callback($args): void
242 749 {
243 - ?>
750 + ?>
244 751 <div class="kng-section-separator"></div>
245 - <h2 id="<?php echo esc_attr($args['id']); ?>"
246 - class="kng-section-title"><?php esc_html_e('Features', 'king-addons'); ?></h2>
247 -<?php
752 + <h2 id="<?php echo esc_attr($args['id']); ?>" class="kng-section-title"><?php esc_html_e('Features', 'king-addons'); ?>
753 + </h2>
754 + <?php
248 755 }
249 756
250 757 function enqueueAdminAssets(): void
251 758 {
@@ -266,8 +773,13 @@
266 773 ");
267 774 }
268 775 }
269 776
777 + public function enqueueGlobalAdminStyles(): void
778 + {
779 + wp_enqueue_style('king-addons-hide-spam', KING_ADDONS_URL . 'includes/admin/css/hide-spam-notifications.css', [], KING_ADDONS_VERSION);
780 + }
781 +
270 782 function enqueueSettingsAssets(): void
271 783 {
272 784 wp_enqueue_style('king-addons-settings', KING_ADDONS_URL . 'includes/admin/css/settings.css', '', KING_ADDONS_VERSION);
273 785 wp_enqueue_style('wp-color-picker');
@@ -291,35 +803,120 @@
291 803 );
292 804
293 805 add_settings_section(
294 806 'king_addons_ai_openai_section',
295 - esc_html__('OpenAI API Settings', 'king-addons'),
807 + esc_html__('AI Provider Settings', 'king-addons'),
296 808 [$this, 'renderAiOpenaiSection'],
297 809 'king-addons-ai-settings'
298 810 );
299 811
300 812 add_settings_field(
813 + 'ai_provider',
814 + esc_html__('AI Provider', 'king-addons'),
815 + [$this, 'renderAiProviderField'],
816 + 'king-addons-ai-settings',
817 + 'king_addons_ai_openai_section'
818 + );
819 +
820 + // Provider specific rows. The row classes let the settings page show only
821 + // the fields belonging to the provider that is currently selected; the
822 + // inactive ones start hidden so they never flash before the script runs.
823 + $active_provider = AI_Provider::getProvider();
824 + $row_class = static function (string $provider) use ($active_provider): array {
825 + $classes = 'ka-ai-provider-row ka-ai-provider-' . $provider;
826 + if ($provider !== $active_provider) {
827 + $classes .= ' ka-ai-row-hidden';
828 + }
829 + return ['class' => $classes];
830 + };
831 +
832 + add_settings_field(
301 833 'openai_api_key',
302 834 esc_html__('OpenAI API Key', 'king-addons'),
303 835 [$this, 'renderAiApiKeyField'],
304 836 'king-addons-ai-settings',
837 + 'king_addons_ai_openai_section',
838 + $row_class(AI_Provider::OPENAI)
839 + );
840 +
841 + add_settings_field(
842 + 'openrouter_api_key',
843 + esc_html__('OpenRouter API Key', 'king-addons'),
844 + [$this, 'renderAiOpenRouterApiKeyField'],
845 + 'king-addons-ai-settings',
846 + 'king_addons_ai_openai_section',
847 + $row_class(AI_Provider::OPENROUTER)
848 + );
849 +
850 + add_settings_field(
851 + 'ai_connection_test',
852 + esc_html__('Connection', 'king-addons'),
853 + [$this, 'renderAiConnectionTestField'],
854 + 'king-addons-ai-settings',
305 855 'king_addons_ai_openai_section'
306 856 );
307 857
308 858 add_settings_field(
309 859 'openai_model',
310 - esc_html__('OpenAI Model', 'king-addons'),
860 + esc_html__('OpenAI Model (for text generation)', 'king-addons'),
311 861 [$this, 'renderAiModelField'],
312 862 'king-addons-ai-settings',
313 - 'king_addons_ai_openai_section'
863 + 'king_addons_ai_openai_section',
864 + $row_class(AI_Provider::OPENAI)
314 865 );
315 866
867 + add_settings_field(
868 + 'openai_vision_model',
869 + esc_html__('OpenAI Model (for image recognition)', 'king-addons'),
870 + [$this, 'renderAiVisionModelField'],
871 + 'king-addons-ai-settings',
872 + 'king_addons_ai_openai_section',
873 + $row_class(AI_Provider::OPENAI)
874 + );
875 +
316 876 // Add image model selector field
317 877 add_settings_field(
318 878 'openai_image_model',
319 - esc_html__('OpenAI Image Model', 'king-addons'),
879 + esc_html__('OpenAI Image Model (for image generation)', 'king-addons'),
320 880 [$this, 'renderAiImageModelField'],
321 881 'king-addons-ai-settings',
882 + 'king_addons_ai_openai_section',
883 + $row_class(AI_Provider::OPENAI)
884 + );
885 +
886 + add_settings_field(
887 + 'openrouter_model',
888 + esc_html__('OpenRouter Model (for text generation)', 'king-addons'),
889 + [$this, 'renderAiOpenRouterModelField'],
890 + 'king-addons-ai-settings',
891 + 'king_addons_ai_openai_section',
892 + $row_class(AI_Provider::OPENROUTER)
893 + );
894 +
895 + add_settings_field(
896 + 'openrouter_vision_model',
897 + esc_html__('OpenRouter Model (for image recognition)', 'king-addons'),
898 + [$this, 'renderAiOpenRouterVisionModelField'],
899 + 'king-addons-ai-settings',
900 + 'king_addons_ai_openai_section',
901 + $row_class(AI_Provider::OPENROUTER)
902 + );
903 +
904 + add_settings_field(
905 + 'openrouter_image_model',
906 + esc_html__('OpenRouter Image Model (for image generation)', 'king-addons'),
907 + [$this, 'renderAiOpenRouterImageModelField'],
908 + 'king-addons-ai-settings',
909 + 'king_addons_ai_openai_section',
910 + $row_class(AI_Provider::OPENROUTER)
911 + );
912 +
913 + // Global content language
914 + add_settings_field(
915 + 'content_language_custom_enable',
916 + esc_html__('Content Language', 'king-addons'),
917 + [$this, 'renderAiContentLanguageField'],
918 + 'king-addons-ai-settings',
322 919 'king_addons_ai_openai_section'
323 920 );
324 921
325 922 // Add Editor Integration section and field
@@ -380,9 +977,8 @@
380 977 [$this, 'renderAiAltTextImageDetailLevelField'],
381 978 'king-addons-ai-settings',
382 979 'king_addons_ai_alt_text_section'
383 980 );
384 -
385 981 // Add Translation Settings section
386 982 add_settings_section(
387 983 'king_addons_ai_translation_section',
388 984 esc_html__('Translation Settings', 'king-addons'),
@@ -427,8 +1023,11 @@
427 1023
428 1024 // AJAX handler for refreshing models.
429 1025 add_action('wp_ajax_king_addons_ai_refresh_models', [$this, 'handleAiRefreshModels']);
430 1026
1027 + // AJAX handler for testing the provider connection.
1028 + add_action('wp_ajax_king_addons_ai_test_connection', [$this, 'handleAiTestConnection']);
1029 +
431 1030 // AJAX handler for generating text via AI
432 1031 add_action('wp_ajax_king_addons_ai_generate_text', [$this, 'handleAiGenerateText']);
433 1032
434 1033 // AJAX handler to change text using AI based on user prompt and original text.
@@ -455,8 +1054,12 @@
455 1054 */
456 1055 public function sanitizeAiSettings(array $input): array
457 1056 {
458 1057 $sanitized = [];
1058 +
1059 + // An absent value resolves to the default rather than being pinned to OpenAI.
1060 + $sanitized['ai_provider'] = AI_Provider::normalizeProvider($input['ai_provider'] ?? '');
1061 +
459 1062 $sanitized['openai_api_key'] = isset($input['openai_api_key'])
460 1063 ? sanitize_text_field($input['openai_api_key'])
461 1064 : '';
462 1065 $sanitized['openai_model'] = isset($input['openai_model'])
@@ -461,12 +1064,36 @@
461 1064 : '';
462 1065 $sanitized['openai_model'] = isset($input['openai_model'])
463 1066 ? sanitize_text_field($input['openai_model'])
464 1067 : '';
1068 + $sanitized['openai_vision_model'] = isset($input['openai_vision_model'])
1069 + ? sanitize_text_field($input['openai_vision_model'])
1070 + : ($sanitized['openai_model'] ?: 'gpt-4o-mini');
465 1071 $sanitized['openai_image_model'] = isset($input['openai_image_model'])
466 1072 ? sanitize_text_field($input['openai_image_model'])
467 1073 : 'gpt-image-1';
468 1074
1075 + // OpenRouter is configured independently, so switching providers back
1076 + // and forth keeps both sets of credentials and models intact.
1077 + $sanitized['openrouter_api_key'] = isset($input['openrouter_api_key'])
1078 + ? sanitize_text_field($input['openrouter_api_key'])
1079 + : '';
1080 + $sanitized['openrouter_model'] = isset($input['openrouter_model'])
1081 + ? sanitize_text_field($input['openrouter_model'])
1082 + : '';
1083 + $sanitized['openrouter_vision_model'] = isset($input['openrouter_vision_model'])
1084 + ? sanitize_text_field($input['openrouter_vision_model'])
1085 + : ($sanitized['openrouter_model'] ?: '');
1086 + $sanitized['openrouter_image_model'] = isset($input['openrouter_image_model'])
1087 + ? sanitize_text_field($input['openrouter_image_model'])
1088 + : '';
1089 +
1090 + // Content language is rendered by this section, so it has to survive the save.
1091 + $sanitized['content_language_custom_enable'] = !empty($input['content_language_custom_enable']);
1092 + $sanitized['content_language_custom'] = isset($input['content_language_custom'])
1093 + ? sanitize_text_field($input['content_language_custom'])
1094 + : '';
1095 +
469 1096 // Sanitize Daily Token Limit.
470 1097 if (isset($input['daily_token_limit'])) {
471 1098 $daily_limit = absint($input['daily_token_limit']);
472 1099 $sanitized['daily_token_limit'] = max(0, $daily_limit); // Ensure non-negative
@@ -474,18 +1101,18 @@
474 1101 $sanitized['daily_token_limit'] = 1000000; // Default to 1 million tokens if not set
475 1102 }
476 1103
477 1104 // Sanitize Enable AI Buttons option.
478 - $sanitized['enable_ai_buttons'] = ! empty($input['enable_ai_buttons']);
1105 + $sanitized['enable_ai_buttons'] = !empty($input['enable_ai_buttons']);
479 1106
480 1107 // Sanitize Enable AI Image Generation button option.
481 - $sanitized['enable_ai_image_generation_button'] = ! empty($input['enable_ai_image_generation_button']);
1108 + $sanitized['enable_ai_image_generation_button'] = !empty($input['enable_ai_image_generation_button']);
482 1109
483 1110 // Sanitize Enable AI Alt Text Button option.
484 - $sanitized['enable_ai_alt_text_button'] = ! empty($input['enable_ai_alt_text_button']);
1111 + $sanitized['enable_ai_alt_text_button'] = !empty($input['enable_ai_alt_text_button']);
485 1112
486 1113 // Sanitize Enable AI Alt Text Auto Generation option.
487 - $sanitized['enable_ai_alt_text_auto_generation'] = ! empty($input['enable_ai_alt_text_auto_generation']);
1114 + $sanitized['enable_ai_alt_text_auto_generation'] = !empty($input['enable_ai_alt_text_auto_generation']);
488 1115
489 1116 // Sanitize AI Alt Text Generation Interval.
490 1117 if (isset($input['ai_alt_text_generation_interval'])) {
491 1118 $interval = absint($input['ai_alt_text_generation_interval']);
@@ -490,19 +1117,55 @@
490 1117 if (isset($input['ai_alt_text_generation_interval'])) {
491 1118 $interval = absint($input['ai_alt_text_generation_interval']);
492 1119 $sanitized['ai_alt_text_generation_interval'] = max(10, min(3600, $interval)); // Between 10 seconds and 1 hour
493 1120 } else {
494 - $sanitized['ai_alt_text_generation_interval'] = 60; // Default to 60 seconds
1121 + $sanitized['ai_alt_text_generation_interval'] = 20; // Default to 20 seconds
495 1122 }
496 1123 // Sanitize Image Detail Level
497 1124 $allowed_detail_levels = ['low', 'high'];
498 1125 $sanitized['ai_alt_text_image_detail_level'] = in_array(($input['ai_alt_text_image_detail_level'] ?? 'low'), $allowed_detail_levels, true)
499 - ? $input['ai_alt_text_image_detail_level']
1126 + ? ($input['ai_alt_text_image_detail_level'] ?? 'low')
500 1127 : 'low';
501 1128
1129 + // Sanitize Auto Tagging settings.
1130 + $sanitized['auto_tagging_max_tags'] = isset($input['auto_tagging_max_tags'])
1131 + ? max(1, min(20, absint($input['auto_tagging_max_tags'])))
1132 + : 5;
1133 + $sanitized['auto_tagging_confidence_threshold'] = isset($input['auto_tagging_confidence_threshold'])
1134 + ? max(0.0, min(1.0, (float) $input['auto_tagging_confidence_threshold']))
1135 + : 0.75;
1136 + $sanitized['auto_tagging_stop_words'] = isset($input['auto_tagging_stop_words'])
1137 + ? sanitize_text_field($input['auto_tagging_stop_words'])
1138 + : '';
1139 +
502 1140 // Sanitize Enable AI Page Translator option
503 - $sanitized['enable_ai_page_translator'] = ! empty($input['enable_ai_page_translator']);
1141 + $sanitized['enable_ai_page_translator'] = !empty($input['enable_ai_page_translator']);
504 1142
1143 + // Validation / feedback (Settings API notice).
1144 + $ai_requires_key = (
1145 + !empty($sanitized['enable_ai_buttons'])
1146 + || !empty($sanitized['enable_ai_image_generation_button'])
1147 + || !empty($sanitized['enable_ai_alt_text_button'])
1148 + || !empty($sanitized['enable_ai_page_translator'])
1149 + );
1150 +
1151 + $active_key = ($sanitized['ai_provider'] === AI_Provider::OPENROUTER)
1152 + ? $sanitized['openrouter_api_key']
1153 + : $sanitized['openai_api_key'];
1154 +
1155 + if ($ai_requires_key && empty($active_key)) {
1156 + add_settings_error(
1157 + 'king_addons_ai',
1158 + 'king_addons_ai_missing_api_key',
1159 + sprintf(
1160 + /* translators: %s: provider name */
1161 + esc_html__('%s API Key is required to enable AI features.', 'king-addons'),
1162 + AI_Provider::getLabel($sanitized['ai_provider'])
1163 + ),
1164 + 'error'
1165 + );
1166 + }
1167 +
505 1168 return $sanitized;
506 1169 }
507 1170
508 1171 /**
@@ -511,9 +1174,9 @@
511 1174 * @return void
512 1175 */
513 1176 public function clearAiModelsCache(): void
514 1177 {
515 - delete_transient('king_addons_ai_models_cache');
1178 + AI_Provider::clearModelsCache();
516 1179 }
517 1180
518 1181 /**
519 1182 * Renders the AI Settings page content.
@@ -521,9 +1184,9 @@
521 1184 * @return void
522 1185 */
523 1186 public function showAiSettingsPage(): void
524 1187 {
525 - if (! current_user_can('manage_options')) {
1188 + if (!current_user_can('manage_options')) {
526 1189 return;
527 1190 }
528 1191 require_once KING_ADDONS_PATH . 'includes/admin/layouts/ai-settings-page.php';
529 1192 $this->enqueueAiSettingsAssets();
@@ -537,9 +1200,9 @@
537 1200 public function enqueueAiSettingsAssets(): void
538 1201 {
539 1202 // Enqueue admin base styles first for proper theming
540 1203 wp_enqueue_style('king-addons-admin', KING_ADDONS_URL . 'includes/admin/css/admin.css', '', KING_ADDONS_VERSION);
541 -
1204 +
542 1205 wp_enqueue_style(
543 1206 'king-addons-ai-settings',
544 1207 KING_ADDONS_URL . 'includes/admin/css/ai-settings.css',
545 1208 ['king-addons-admin'], // Depend on admin base styles
@@ -557,28 +1220,167 @@
557 1220 wp_localize_script(
558 1221 'king-addons-ai-settings',
559 1222 'KingAddonsAiSettings',
560 1223 [
561 - 'ajax_url' => admin_url('admin-ajax.php'),
562 - 'nonce' => wp_create_nonce('king_addons_ai_refresh_models_nonce'),
1224 + 'ajax_url' => admin_url('admin-ajax.php'),
1225 + 'nonce' => wp_create_nonce('king_addons_ai_refresh_models_nonce'),
563 1226 'refreshing_text' => esc_html__('Refreshing...', 'king-addons'),
564 - 'refreshed_text' => esc_html__('List updated.', 'king-addons'),
565 - 'error_text' => esc_html__('Error updating list.', 'king-addons'),
1227 + 'refreshed_text' => esc_html__('List updated.', 'king-addons'),
1228 + 'error_text' => esc_html__('Error updating list.', 'king-addons'),
1229 + 'testing_text' => esc_html__('Testing connection...', 'king-addons'),
1230 + 'test_failed_text' => esc_html__('Connection failed.', 'king-addons'),
1231 + 'free_models_label' => esc_html__('Free models', 'king-addons'),
1232 + 'paid_models_label' => esc_html__('Paid models', 'king-addons'),
566 1233 ]
567 1234 );
568 1235 }
569 1236
570 1237 /**
571 - * Renders description for OpenAI API Settings section.
1238 + * Renders description for the AI Provider Settings section.
572 1239 *
573 1240 * @return void
574 1241 */
575 1242 public function renderAiOpenaiSection(): void
576 1243 {
577 - echo '<p>' . esc_html__('Enter your OpenAI API key and select the model for AI features.', 'king-addons') . '</p>';
1244 + echo '<p>' . esc_html__('Choose an AI provider, enter its API key and select the models used by the AI features.', 'king-addons') . '</p>';
578 1245 }
579 1246
580 1247 /**
1248 + * Renders the AI provider selector.
1249 + *
1250 + * @return void
1251 + */
1252 + public function renderAiProviderField(): void
1253 + {
1254 + $selected = AI_Provider::getProvider();
1255 +
1256 + echo '<select name="king_addons_ai_options[ai_provider]" id="king-addons-ai-provider">';
1257 + foreach (AI_Provider::getProviders() as $provider => $label) {
1258 + printf(
1259 + '<option value="%s" %s>%s</option>',
1260 + esc_attr($provider),
1261 + selected($selected, $provider, false),
1262 + esc_html($label)
1263 + );
1264 + }
1265 + echo '</select>';
1266 +
1267 + echo '<p class="description">' . esc_html__('OpenAI talks to the OpenAI API directly. OpenRouter is a gateway to models from many vendors, including a number of free ones, through a single API key.', 'king-addons') . '</p>';
1268 + }
1269 +
1270 + /**
1271 + * Renders the OpenRouter API Key input field.
1272 + *
1273 + * @return void
1274 + */
1275 + public function renderAiOpenRouterApiKeyField(): void
1276 + {
1277 + $options = get_option('king_addons_ai_options', []);
1278 + $api_key = $options['openrouter_api_key'] ?? '';
1279 + printf(
1280 + '<input type="password" name="king_addons_ai_options[openrouter_api_key]" id="king-addons-openrouter-api-key" value="%s" class="regular-text" autocomplete="off" />',
1281 + esc_attr($api_key)
1282 + );
1283 + echo '<p class="description">';
1284 + printf(
1285 + /* translators: %1$s: opening link tag, %2$s: closing link tag */
1286 + esc_html__('Get your API key from %1$sOpenRouter Keys%2$s. Saving the key will attempt to fetch the available models.', 'king-addons'),
1287 + '<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer">',
1288 + '</a>'
1289 + );
1290 + echo '</p>';
1291 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1292 + echo '<strong>' . esc_html__('Info:', 'king-addons') . '</strong> ';
1293 + echo esc_html__('Free models can be used without adding credit. Paid models, including every image generation model, require credit on your OpenRouter account.', 'king-addons');
1294 + echo '</div>';
1295 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1296 + echo '<strong class="ka-ai-notice-title">' . esc_html__('Useful OpenRouter Links:', 'king-addons') . '</strong>';
1297 + echo '<ul class="ka-ai-links-list">';
1298 + $links = [
1299 + 'Models & Pricing' => 'https://openrouter.ai/models',
1300 + 'API Keys' => 'https://openrouter.ai/keys',
1301 + 'Activity Dashboard' => 'https://openrouter.ai/activity',
1302 + 'Credits' => 'https://openrouter.ai/settings/credits',
1303 + 'Limits' => 'https://openrouter.ai/docs/api-reference/limits',
1304 + ];
1305 + foreach ($links as $label => $url) {
1306 + printf(
1307 + '<li><a href="%s" target="_blank" rel="noopener noreferrer">%s</a></li>',
1308 + esc_url($url),
1309 + esc_html($label)
1310 + );
1311 + }
1312 + echo '</ul></div>';
1313 + }
1314 +
1315 + /**
1316 + * Renders the "Test Connection" control.
1317 + *
1318 + * @return void
1319 + */
1320 + public function renderAiConnectionTestField(): void
1321 + {
1322 + echo '<button type="button" id="king-addons-ai-test-connection-button" class="button button-secondary">' . esc_html__('Test Connection', 'king-addons') . '</button>';
1323 + echo '<button type="button" id="king-addons-ai-refresh-models-button" class="button button-secondary" style="margin-left:10px; vertical-align:middle;">' . esc_html__('Refresh List', 'king-addons') . '</button>';
1324 + echo '<span class="spinner" id="king-addons-ai-test-connection-spinner" style="float:none; vertical-align:middle;"></span>';
1325 + echo '<span class="spinner" id="king-addons-ai-refresh-models-spinner" style="float:none; vertical-align:middle;"></span>';
1326 + echo '<span id="king-addons-ai-test-connection-status" style="margin-left:5px; vertical-align:middle;"></span>';
1327 + echo '<span id="king-addons-ai-refresh-models-status" style="margin-left:5px; vertical-align:middle;"></span>';
1328 + echo '<p class="description">' . esc_html__('Test Connection checks the API key currently typed above against the selected provider — it does not have to be saved first. Refresh List re-fetches that provider\'s model catalogue.', 'king-addons') . '</p>';
1329 + }
1330 +
1331 + /**
1332 + * Renders the OpenRouter text model dropdown.
1333 + *
1334 + * @return void
1335 + */
1336 + public function renderAiOpenRouterModelField(): void
1337 + {
1338 + $selected = AI_Provider::getModel('text', AI_Provider::OPENROUTER);
1339 + AI_Provider::renderModelSelect(
1340 + 'king_addons_ai_options[openrouter_model]',
1341 + $selected,
1342 + AI_Provider::getModelsFor('text', AI_Provider::OPENROUTER),
1343 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'text']
1344 + );
1345 + echo '<p class="description">' . esc_html__('Free models are listed first, then paid ones, each block in alphabetical order. Use the Refresh List button to pull the latest catalogue.', 'king-addons') . '</p>';
1346 + }
1347 +
1348 + /**
1349 + * Renders the OpenRouter vision model dropdown.
1350 + *
1351 + * @return void
1352 + */
1353 + public function renderAiOpenRouterVisionModelField(): void
1354 + {
1355 + $selected = AI_Provider::getModel('vision', AI_Provider::OPENROUTER);
1356 + AI_Provider::renderModelSelect(
1357 + 'king_addons_ai_options[openrouter_vision_model]',
1358 + $selected,
1359 + AI_Provider::getModelsFor('vision', AI_Provider::OPENROUTER),
1360 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'vision']
1361 + );
1362 + echo '<p class="description">' . esc_html__('Only models that accept image input are listed. Used for the Alt Text Generator and other vision requests.', 'king-addons') . '</p>';
1363 + }
1364 +
1365 + /**
1366 + * Renders the OpenRouter image generation model dropdown.
1367 + *
1368 + * @return void
1369 + */
1370 + public function renderAiOpenRouterImageModelField(): void
1371 + {
1372 + $selected = AI_Provider::getModel('image', AI_Provider::OPENROUTER);
1373 + AI_Provider::renderModelSelect(
1374 + 'king_addons_ai_options[openrouter_image_model]',
1375 + $selected,
1376 + AI_Provider::getModelsFor('image', AI_Provider::OPENROUTER),
1377 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'image']
1378 + );
1379 + echo '<p class="description">' . esc_html__('Only models that return images are listed. Image generation is never free — your OpenRouter account needs credit.', 'king-addons') . '</p>';
1380 + }
1381 +
1382 + /**
581 1383 * Renders the OpenAI API Key input field.
582 1384 *
583 1385 * @return void
584 1386 */
@@ -596,30 +1398,29 @@
596 1398 '<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">',
597 1399 '</a>'
598 1400 );
599 1401 echo '</p>';
600 - echo '<div style="background: #ffebe8; color: #a00; border: 1px solid #a00; padding: 10px; margin: 10px 0; border-radius: 4px;">';
1402 + echo '<div class="ka-ai-notice ka-ai-notice-warning">';
601 1403 echo '<strong>' . esc_html__('Important:', 'king-addons') . '</strong> ';
602 1404 echo esc_html__('You must top up your OpenAI account balance by at least $5 for the API to work. Free accounts are not supported.', 'king-addons');
603 1405 echo '</div>';
604 - echo '<div style="background: #e7f3fe; color: #084d7a; border: 1px solid #b6e0fe; padding: 10px; margin: 10px 0; border-radius: 4px;">';
605 - echo '<span style="font-weight: bold; color: #084d7a;">' . esc_html__('Info:', 'king-addons') . '</span> ';
1406 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1407 + echo '<strong>' . esc_html__('Info:', 'king-addons') . '</strong> ';
606 1408 echo esc_html__('With GPT-4o-mini, a $5 balance is enough for roughly 130,000–150,000 text generations.', 'king-addons');
607 1409 echo '</div>';
608 - echo '<div style="background: #e7f3fe; color: #084d7a; border: 1px solid #b6e0fe; padding: 10px; margin: 10px 0; border-radius: 4px;">';
609 - echo '<span style="font-weight: bold; color: #084d7a; display: block; margin-bottom: 4px;">' . esc_html__('Useful OpenAI Links:', 'king-addons') . '</span>';
610 - echo '<ul style="margin: 0 0 0 18px; padding: 0; list-style: disc;">';
1410 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1411 + echo '<strong class="ka-ai-notice-title">' . esc_html__('Useful OpenAI Links:', 'king-addons') . '</strong>';
1412 + echo '<ul class="ka-ai-links-list">';
611 1413 $links = [
612 - 'API Pricing' => 'https://openai.com/api/pricing/',
613 - // 'API Documentation' => 'https://platform.openai.com/docs',
614 - 'API Keys' => 'https://platform.openai.com/api-keys',
615 - 'Usage Dashboard' => 'https://platform.openai.com/account/usage',
616 - 'Billing Overview' => 'https://platform.openai.com/account/billing/overview',
617 - 'Rate Limits' => 'https://openai.com/pricing#rate-limits',
1414 + 'API Pricing' => 'https://openai.com/api/pricing/',
1415 + 'API Keys' => 'https://platform.openai.com/api-keys',
1416 + 'Usage Dashboard' => 'https://platform.openai.com/account/usage',
1417 + 'Billing Overview' => 'https://platform.openai.com/account/billing/overview',
1418 + 'Rate Limits' => 'https://openai.com/pricing#rate-limits',
618 1419 ];
619 1420 foreach ($links as $label => $url) {
620 1421 printf(
621 - '<li><a href="%s" target="_blank" rel="noopener noreferrer" style="color: #084d7a; text-decoration: underline;">%s</a></li>',
1422 + '<li><a href="%s" target="_blank" rel="noopener noreferrer">%s</a></li>',
622 1423 esc_url($url),
623 1424 esc_html($label)
624 1425 );
625 1426 }
@@ -632,96 +1433,53 @@
632 1433 * @return void
633 1434 */
634 1435 public function renderAiModelField(): void
635 1436 {
636 - $options = get_option('king_addons_ai_options', []);
637 - $selected = $options['openai_model'] ?? '';
638 - $models = $this->getAiAvailableModels();
639 - printf(
640 - '<select name="king_addons_ai_options[openai_model]" %s>',
641 - empty($models) ? 'disabled' : ''
1437 + $selected = AI_Provider::getModel('text', AI_Provider::OPENAI);
1438 + AI_Provider::renderModelSelect(
1439 + 'king_addons_ai_options[openai_model]',
1440 + $selected,
1441 + AI_Provider::getModelsFor('text', AI_Provider::OPENAI),
1442 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'text']
642 1443 );
643 - if (!empty($models)) {
644 - foreach ($models as $id => $label) {
645 - printf(
646 - '<option value="%s" %s>%s</option>',
647 - esc_attr($id),
648 - selected($selected, $id, false),
649 - esc_html($label)
650 - );
651 - }
652 - } else {
653 - echo '<option value="">' . esc_html__('Could not fetch models. Check API key?', 'king-addons') . '</option>';
654 - }
655 - echo '</select>';
656 - echo '<button type="button" id="king-addons-ai-refresh-models-button" class="button button-secondary" style="margin-left:10px; vertical-align:middle;">' . esc_html__('Refresh List', 'king-addons') . '</button>';
657 - echo '<span class="spinner" id="king-addons-ai-refresh-models-spinner" style="float:none; vertical-align:middle;"></span>';
658 - echo '<span id="king-addons-ai-refresh-models-status" style="margin-left:5px; vertical-align:middle;"></span>';
659 1444 echo '<p class="description">' . esc_html__('Select an available OpenAI model capable of processing text. We recommend GPT-4o-mini or GPT-4.1-nano for best results. The list of models is cached indefinitely until manually refreshed.', 'king-addons') . '</p>';
660 1445 }
661 1446
662 1447 /**
663 - * Fetches the list of OpenAI models via API.
1448 + * Renders the vision model selection dropdown field.
664 1449 *
665 - * @param string|null $api_key API key to use.
666 - * @return array|\WP_Error Model list or error.
1450 + * @return void
667 1451 */
668 - private function fetchAiOpenaiModels(?string $api_key)
1452 + public function renderAiVisionModelField(): void
669 1453 {
670 - if (empty($api_key)) {
671 - return new \WP_Error('missing_key', esc_html__('API key is required to fetch models.', 'king-addons'));
672 - }
673 - $endpoint = 'https://api.openai.com/v1/models';
674 - $response = wp_remote_get($endpoint, [
675 - 'headers' => ['Authorization' => 'Bearer ' . $api_key],
676 - 'timeout' => 20,
677 - ]);
678 - if (is_wp_error($response)) {
679 - return $response;
680 - }
681 - $code = wp_remote_retrieve_response_code($response);
682 - $body = wp_remote_retrieve_body($response);
683 - $data = json_decode($body, true);
684 - if ($code !== 200 || empty($data['data']) || !is_array($data['data'])) {
685 - $message = $data['error']['message'] ?? esc_html__('Invalid response from API.', 'king-addons');
686 - return new \WP_Error('api_error', $message, ['status' => $code]);
687 - }
688 - $list = [];
689 - foreach ($data['data'] as $model) {
690 - if (isset($model['id'])) {
691 - $list[$model['id']] = $model['id'];
692 - }
693 - }
694 - ksort($list);
695 - if (empty($list)) {
696 - return new \WP_Error('no_models', esc_html__('No models found via API.', 'king-addons'));
697 - }
698 - return $list;
1454 + $options = get_option('king_addons_ai_options', []);
1455 + $selected = $options['openai_vision_model'] ?? ($options['openai_model'] ?? 'gpt-4o-mini');
1456 + AI_Provider::renderModelSelect(
1457 + 'king_addons_ai_options[openai_vision_model]',
1458 + (string) $selected,
1459 + AI_Provider::getModelsFor('vision', AI_Provider::OPENAI),
1460 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'vision']
1461 + );
1462 + echo '<p class="description">' . esc_html__('Select the default model used for AI image analysis tasks (Alt Text Generator and related vision requests).', 'king-addons') . '</p>';
699 1463 }
700 1464
701 1465 /**
702 - * Retrieves available models, using cache if possible.
1466 + * Builds the model lists the settings page needs, keyed by purpose.
703 1467 *
704 - * @return array Model list.
1468 + * @param string $provider Provider slug.
1469 + * @return array<string, array<int, array<string, mixed>>>
705 1470 */
706 - private function getAiAvailableModels(): array
1471 + private function getAiModelPayload(string $provider): array
707 1472 {
708 - $cached = get_transient('king_addons_ai_models_cache');
709 - if (false !== $cached && is_array($cached)) {
710 - return $cached;
711 - }
712 - $options = get_option('king_addons_ai_options', []);
713 - $api_key = $options['openai_api_key'] ?? null;
714 - $fetched = $this->fetchAiOpenaiModels($api_key);
715 - if (!is_wp_error($fetched)) {
716 - set_transient('king_addons_ai_models_cache', $fetched, 0);
717 - return $fetched;
718 - }
719 - return ['gpt-4o-mini' => 'GPT-4o-mini', 'gpt-4.1-nano' => 'GPT-4.1-nano'];
1473 + return [
1474 + 'text' => AI_Provider::getModelsFor('text', $provider),
1475 + 'vision' => AI_Provider::getModelsFor('vision', $provider),
1476 + 'image' => AI_Provider::getModelsFor('image', $provider),
1477 + ];
720 1478 }
721 1479
722 1480 /**
723 - * Handles AJAX request to refresh model list.
1481 + * Handles AJAX request to refresh the model list of the selected provider.
724 1482 *
725 1483 * @return void
726 1484 */
727 1485 public function handleAiRefreshModels(): void
@@ -729,27 +1487,76 @@
729 1487 check_ajax_referer('king_addons_ai_refresh_models_nonce', 'nonce');
730 1488 if (!current_user_can('manage_options')) {
731 1489 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
732 1490 }
733 - $options = get_option('king_addons_ai_options', []);
734 - $api_key = $options['openai_api_key'] ?? null;
735 - if (empty($api_key)) {
1491 +
1492 + $provider = AI_Provider::normalizeProvider(
1493 + isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : AI_Provider::getProvider()
1494 + );
1495 +
1496 + // Allow refreshing against a key that has been typed but not saved yet.
1497 + $posted_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : '';
1498 + $api_key = ($posted_key !== '') ? $posted_key : AI_Provider::getApiKey($provider);
1499 +
1500 + // OpenRouter publishes its catalogue without authentication.
1501 + if ($api_key === '' && $provider !== AI_Provider::OPENROUTER) {
736 1502 wp_send_json_error(['message' => esc_html__('API key is not set.', 'king-addons')], 400);
737 1503 }
738 - $this->clearAiModelsCache();
739 - $models = $this->fetchAiOpenaiModels($api_key);
1504 +
1505 + $models = AI_Provider::fetchModels($provider, $api_key);
740 1506 if (is_wp_error($models)) {
741 1507 wp_send_json_error(['message' => $models->get_error_message()], 500);
742 1508 }
743 - if (empty($models)) {
744 - wp_send_json_error(['message' => esc_html__('No models returned by API.', 'king-addons')], 500);
1509 +
1510 + set_transient(
1511 + AI_Provider::getCacheKey($provider),
1512 + $models,
1513 + ($provider === AI_Provider::OPENROUTER) ? DAY_IN_SECONDS : 0
1514 + );
1515 +
1516 + wp_send_json_success([
1517 + 'provider' => $provider,
1518 + 'models' => $this->getAiModelPayload($provider),
1519 + ]);
1520 + }
1521 +
1522 + /**
1523 + * Handles AJAX request to verify the provider connection.
1524 + *
1525 + * @return void
1526 + */
1527 + public function handleAiTestConnection(): void
1528 + {
1529 + check_ajax_referer('king_addons_ai_refresh_models_nonce', 'nonce');
1530 + if (!current_user_can('manage_options')) {
1531 + wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
745 1532 }
746 - set_transient('king_addons_ai_models_cache', $models, 0);
747 - wp_send_json_success(['models' => $models]);
1533 +
1534 + $provider = AI_Provider::normalizeProvider(
1535 + isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : AI_Provider::getProvider()
1536 + );
1537 +
1538 + // Test what is in the field right now, so a key can be verified before
1539 + // it is saved. An empty field falls back to the stored key.
1540 + $posted_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : '';
1541 + $stored_key = AI_Provider::getApiKey($provider);
1542 + $api_key = ($posted_key !== '') ? $posted_key : $stored_key;
1543 + $unsaved = ($posted_key !== '' && $posted_key !== $stored_key);
1544 +
1545 + $result = AI_Provider::testConnection($provider, $api_key);
1546 + if (is_wp_error($result)) {
1547 + wp_send_json_error(['message' => $result->get_error_message()], 400);
1548 + }
1549 +
1550 + if ($unsaved) {
1551 + $result .= ' ' . esc_html__('Save the settings to start using it.', 'king-addons');
1552 + }
1553 +
1554 + wp_send_json_success(['message' => $result, 'unsaved' => $unsaved]);
748 1555 }
749 1556
750 1557 /**
751 - * AJAX handler to generate text using OpenAI.
1558 + * AJAX handler to generate text using the configured AI provider.
752 1559 *
753 1560 * @return void
754 1561 */
755 1562 public function handleAiGenerateText(): void
@@ -754,9 +1561,9 @@
754 1561 */
755 1562 public function handleAiGenerateText(): void
756 1563 {
757 1564 check_ajax_referer('king_addons_ai_generate_nonce', 'nonce');
758 - if (! current_user_can('manage_options')) {
1565 + if (!current_user_can('manage_options')) {
759 1566 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
760 1567 }
761 1568 $field_name = sanitize_text_field($_POST['field_name'] ?? '');
762 1569 // Accept 'prompt' parameter (new) but fall back to 'value' parameter (old) for backwards compatibility
@@ -766,11 +1573,11 @@
766 1573
767 1574 // Get editor type if provided
768 1575 $editor_type = sanitize_text_field($_POST['editor_type'] ?? 'text');
769 1576
770 - $options = get_option('king_addons_ai_options', []);
771 - $api_key = $options['openai_api_key'] ?? '';
772 - $model = $options['openai_model'] ?? '';
1577 + $options = get_option('king_addons_ai_options', []);
1578 + $api_key = AI_Provider::getApiKey();
1579 + $model = AI_Provider::getTextModel();
773 1580
774 1581 if (empty($api_key) || empty($model)) {
775 1582 wp_send_json_error(['message' => esc_html__('API key or model not set.', 'king-addons')], 400);
776 1583 }
@@ -784,9 +1591,13 @@
784 1591 $current_usage = $this->getAiDailyUsage();
785 1592
786 1593 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
787 1594 wp_send_json_error([
788 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
1595 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
1596 + // This is the plugin's own cap, not the provider's, so the
1597 + // client must not retry it or blame the AI provider.
1598 + 'code' => 'local_limit',
1599 + 'retryable' => false,
789 1600 ], 429);
790 1601 }
791 1602
792 1603 // System instruction based on editor type
@@ -796,9 +1607,9 @@
796 1607 if ($editor_type === 'wysiwyg') {
797 1608 $system_instruction = 'You are a helpful content assistant for a rich text editor. Provide content with proper HTML formatting. Use <p> tags for paragraphs with appropriate spacing between them. If relevant, use other HTML formatting like <strong>, <em>, <ul>, <ol>, etc. for better readability and structure. IMPORTANT: Do NOT wrap your HTML in code fences (``` or ```html). Respond ONLY with the actual HTML content.';
798 1609 }
799 1610
800 - // Prepare request to OpenAI Chat Completions
1611 + // Prepare request to the provider's Chat Completions endpoint
801 1612 $messages = [
802 1613 ['role' => 'system', 'content' => $system_instruction],
803 1614 ['role' => 'user', 'content' => $prompt]
804 1615 ];
@@ -808,20 +1619,17 @@
808 1619 $messages[1]['content'] .= "\n\nOutput should be properly formatted HTML with <p> tags for paragraphs, maintaining good spacing and readability. Do NOT use code fences (```html or ```) in your response - provide just the clean HTML.";
809 1620 }
810 1621
811 1622 $response = wp_remote_post(
812 - 'https://api.openai.com/v1/chat/completions',
1623 + AI_Provider::getChatEndpoint(),
813 1624 [
814 - 'headers' => [
815 - 'Authorization' => 'Bearer ' . $api_key,
816 - 'Content-Type' => 'application/json',
817 - ],
818 - 'body' => wp_json_encode([
819 - 'model' => $model,
1625 + 'headers' => AI_Provider::getHeaders(),
1626 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload([
1627 + 'model' => $model,
820 1628 'messages' => $messages,
821 1629 'max_tokens' => 500,
822 1630 'temperature' => 0.7, // Slight creativity for better content
823 - ]),
1631 + ])),
824 1632 'timeout' => 30,
825 1633 ]
826 1634 );
827 1635
@@ -832,9 +1640,9 @@
832 1640 $code = wp_remote_retrieve_response_code($response);
833 1641 $data = json_decode(wp_remote_retrieve_body($response), true);
834 1642
835 1643 if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
836 - $error_msg = $data['error']['message'] ?? esc_html__('AI API error.', 'king-addons');
1644 + $error_msg = AI_Provider::extractErrorMessage($data, esc_html__('AI API error.', 'king-addons'));
837 1645 wp_send_json_error(['message' => $error_msg], 500);
838 1646 }
839 1647
840 1648 $generated = trim($data['choices'][0]['message']['content']);
@@ -868,19 +1676,19 @@
868 1676 */
869 1677 public function handleAiChangeText(): void
870 1678 {
871 1679 check_ajax_referer('king_addons_ai_change_nonce', 'nonce');
872 - if (! current_user_can('manage_options')) {
1680 + if (!current_user_can('manage_options')) {
873 1681 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
874 1682 }
875 1683 $field_name = isset($_POST['field_name']) ? sanitize_text_field(wp_unslash($_POST['field_name'])) : '';
876 - $prompt = isset($_POST['prompt']) ? sanitize_text_field(wp_unslash($_POST['prompt'])) : '';
877 - $original = isset($_POST['original']) ? wp_kses_post(wp_unslash($_POST['original'])) : '';
1684 + $prompt = isset($_POST['prompt']) ? sanitize_text_field(wp_unslash($_POST['prompt'])) : '';
1685 + $original = isset($_POST['original']) ? wp_kses_post(wp_unslash($_POST['original'])) : '';
878 1686 $instruction_context = isset($_POST['instruction_context']) ? sanitize_textarea_field(wp_unslash($_POST['instruction_context'])) : '';
879 1687
880 1688 $options = get_option('king_addons_ai_options', []);
881 - $api_key = $options['openai_api_key'] ?? '';
882 - $model = $options['openai_model'] ?? '';
1689 + $api_key = AI_Provider::getApiKey();
1690 + $model = AI_Provider::getTextModel();
883 1691
884 1692 if (empty($api_key) || empty($model) || empty($prompt) || empty($original)) {
885 1693 wp_send_json_error(['message' => esc_html__('Missing data for AI change.', 'king-addons')], 400);
886 1694 }
@@ -890,9 +1698,13 @@
890 1698 $current_usage = $this->getAiDailyUsage();
891 1699
892 1700 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
893 1701 wp_send_json_error([
894 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
1702 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
1703 + // This is the plugin's own cap, not the provider's, so the
1704 + // client must not retry it or blame the AI provider.
1705 + 'code' => 'local_limit',
1706 + 'retryable' => false,
895 1707 ], 429);
896 1708 }
897 1709
898 1710 // Default instruction if none provided
@@ -1022,9 +1834,9 @@
1022 1834 $number_text = $numeric_match[1] ?? '';
1023 1835
1024 1836 // Convert text numbers to digits
1025 1837 if (is_numeric($number_text)) {
1026 - $requested_paragraphs = (int)$number_text;
1838 + $requested_paragraphs = (int) $number_text;
1027 1839 } else {
1028 1840 // For words like "several", "few", "couple", etc.
1029 1841 switch (strtolower($number_text)) {
1030 1842 // Words meaning approximately "2"
@@ -1098,13 +1910,13 @@
1098 1910 $body = [
1099 1911 'model' => $model,
1100 1912 'messages' => [
1101 1913 [
1102 - 'role' => 'system',
1914 + 'role' => 'system',
1103 1915 'content' => $system_message,
1104 1916 ],
1105 1917 [
1106 - 'role' => 'user',
1918 + 'role' => 'user',
1107 1919 'content' => ($contains_add_keyword && $requested_paragraphs > 0)
1108 1920 ? sprintf(
1109 1921 "Original Text for context: %s\n\nInstruction: Generate %d new paragraphs to add to this text, following the same style and continuing the topic. Return ONLY the new paragraphs.",
1110 1922 $original,
@@ -1143,15 +1955,12 @@
1143 1955 $body['max_tokens'] = 15000;
1144 1956 }
1145 1957
1146 1958 $response = wp_remote_post(
1147 - 'https://api.openai.com/v1/chat/completions',
1959 + AI_Provider::getChatEndpoint(),
1148 1960 [
1149 - 'headers' => [
1150 - 'Authorization' => 'Bearer ' . $api_key,
1151 - 'Content-Type' => 'application/json',
1152 - ],
1153 - 'body' => wp_json_encode($body),
1961 + 'headers' => AI_Provider::getHeaders(),
1962 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload($body)),
1154 1963 'timeout' => 30,
1155 1964 ]
1156 1965 );
1157 1966
@@ -1162,9 +1971,9 @@
1162 1971 $code = wp_remote_retrieve_response_code($response);
1163 1972 $data = json_decode(wp_remote_retrieve_body($response), true);
1164 1973
1165 1974 if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
1166 - $error_msg = $data['error']['message'] ?? esc_html__('AI change error.', 'king-addons');
1975 + $error_msg = AI_Provider::extractErrorMessage($data, esc_html__('AI change error.', 'king-addons'));
1167 1976 wp_send_json_error(['message' => $error_msg], 500);
1168 1977 }
1169 1978
1170 1979 $changed = trim($data['choices'][0]['message']['content']);
@@ -1340,15 +2149,15 @@
1340 2149 $options = get_option('king_addons_ai_options', []);
1341 2150 $daily_limit = isset($options['daily_token_limit']) ? intval($options['daily_token_limit']) : self::DEFAULT_DAILY_TOKEN_LIMIT;
1342 2151 $daily_used = $this->getAiDailyUsage();
1343 2152 // Check if API key and model are set
1344 - $api_key = $options['openai_api_key'] ?? '';
1345 - $model = $options['openai_model'] ?? '';
2153 + $api_key = AI_Provider::getApiKey();
2154 + $model = AI_Provider::getTextModel();
1346 2155 $api_key_valid = !empty($api_key) && !empty($model);
1347 2156
1348 2157 wp_send_json_success([
1349 - 'daily_used' => $daily_used,
1350 - 'daily_limit' => $daily_limit,
2158 + 'daily_used' => $daily_used,
2159 + 'daily_limit' => $daily_limit,
1351 2160 'limit_reached' => ($daily_limit > 0 && $daily_used >= $daily_limit),
1352 2161 'api_key_valid' => $api_key_valid,
1353 2162 ]);
1354 2163 }
@@ -1360,9 +2169,9 @@
1360 2169 */
1361 2170 public function handleAiImageCheckLimits(): void
1362 2171 {
1363 2172 check_ajax_referer('king_addons_ai_generate_image_nonce', 'nonce');
1364 - if (! current_user_can('manage_options')) {
2173 + if (!current_user_can('manage_options')) {
1365 2174 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
1366 2175 }
1367 2176
1368 2177 $options = get_option('king_addons_ai_options', []);
@@ -1368,19 +2177,19 @@
1368 2177 $options = get_option('king_addons_ai_options', []);
1369 2178 $daily_limit = isset($options['daily_token_limit']) ? intval($options['daily_token_limit']) : self::DEFAULT_DAILY_TOKEN_LIMIT;
1370 2179 $daily_used = $this->getAiDailyUsage();
1371 2180 // Check if API key and model are set
1372 - $api_key = $options['openai_api_key'] ?? '';
1373 - $model = $options['openai_model'] ?? '';
2181 + $api_key = AI_Provider::getApiKey();
2182 + $model = AI_Provider::getTextModel();
1374 2183 $api_key_valid = !empty($api_key) && !empty($model);
1375 2184
1376 2185 wp_send_json_success([
1377 - 'daily_used' => $daily_used,
1378 - 'daily_limit' => $daily_limit,
2186 + 'daily_used' => $daily_used,
2187 + 'daily_limit' => $daily_limit,
1379 2188 'limit_reached' => ($daily_limit > 0 && $daily_used >= $daily_limit),
1380 2189 'api_key_valid' => $api_key_valid,
1381 2190 ]);
1382 -
2191 +
1383 2192 }
1384 2193
1385 2194
1386 2195 /**
@@ -1462,9 +2271,9 @@
1462 2271 */
1463 2272 public function renderAiAltTextAutoGenerationField(): void
1464 2273 {
1465 2274 $options = get_option('king_addons_ai_options', []);
1466 - $is_pro = !king_addons_freemius()->can_use_premium_code();
2275 + $is_pro = !king_addons_freemius()->can_use_premium_code();
1467 2276 // Default to false if option has never been saved, otherwise use saved value
1468 2277 $enabled = array_key_exists('enable_ai_alt_text_auto_generation', $options) ? (bool) $options['enable_ai_alt_text_auto_generation'] : false;
1469 2278 printf(
1470 2279 '<label><input type="checkbox"' . ($is_pro ? ' disabled' : '') . ' name="king_addons_ai_options[enable_ai_alt_text_auto_generation]" value="1" %s /> %s</label>',
@@ -1482,14 +2291,14 @@
1482 2291 */
1483 2292 public function renderAiAltTextIntervalField(): void
1484 2293 {
1485 2294 $options = get_option('king_addons_ai_options', []);
1486 - $interval = isset($options['ai_alt_text_generation_interval']) ? (int) $options['ai_alt_text_generation_interval'] : 60;
2295 + $interval = isset($options['ai_alt_text_generation_interval']) ? (int) $options['ai_alt_text_generation_interval'] : 20;
1487 2296 printf(
1488 - '<input type="number" name="king_addons_ai_options[ai_alt_text_generation_interval]" value="%d" min="10" max="3600" placeholder="60" />',
2297 + '<input type="number" name="king_addons_ai_options[ai_alt_text_generation_interval]" value="%d" min="10" max="3600" placeholder="20" />',
1489 2298 $interval
1490 2299 );
1491 - echo '<p class="description">' . esc_html__('How often (in seconds) the system should process alt text generation queue. Recommended: 60 seconds to avoid OpenAI API rate limits. Lower values may cause API errors during high usage periods. Range: 10-3600 seconds.', 'king-addons') . '</p>';
2300 + echo '<p class="description">' . esc_html__('How often (in seconds) the system should process alt text generation queue. Recommended: 20 seconds. Lower values process faster; higher values reduce API load. Range: 10-3600 seconds.', 'king-addons') . '</p>';
1492 2301 }
1493 2302
1494 2303 /**
1495 2304 * Renders the image model selection dropdown field.
@@ -1520,163 +2329,222 @@
1520 2329 echo '<p class="description">';
1521 2330 printf(
1522 2331 /* translators: %1$s: URL to OpenAI Organization Settings */
1523 2332 wp_kses(
1524 - __( 'Select the default model for AI image generation. By default, the model is DALL·E 3. For now, your organization must be verified to use the model GPT Image 1. Please go to <a href="%1$s" target="_blank" rel="noopener noreferrer">OpenAI Organization Settings</a> to verify. If you just verified, it can take up to 15 minutes for access to propagate.', 'king-addons' ),
1525 - [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [] ] ]
2333 + __('Select the default model for AI image generation. By default, the model is DALL·E 3. For now, your organization must be verified to use the model GPT Image 1. Please go to <a href="%1$s" target="_blank" rel="noopener noreferrer">OpenAI Organization Settings</a> to verify. If you just verified, it can take up to 15 minutes for access to propagate.', 'king-addons'),
2334 + ['a' => ['href' => [], 'target' => [], 'rel' => []]]
1526 2335 ),
1527 - esc_url( 'https://platform.openai.com/settings/organization/general' )
2336 + esc_url('https://platform.openai.com/settings/organization/general')
1528 2337 );
1529 2338 echo '</p>';
1530 2339 }
1531 2340
1532 2341 /**
1533 - * AJAX handler to generate images using OpenAI.
2342 + * AJAX handler to generate images using the configured AI provider.
1534 2343 *
1535 2344 * @return void
1536 2345 */
2346 + public function set_openai_curl_options($handle, $r, $url): void
2347 + {
2348 + if (!is_string($url) || $url === '') {
2349 + return;
2350 + }
2351 +
2352 + // Apply these cURL options only to AI provider requests to avoid affecting other outbound HTTP calls.
2353 + if (!AI_Provider::isProviderUrl($url)) {
2354 + return;
2355 + }
2356 +
2357 + // Increase connect timeout to 60s, and total timeout to 5m.
2358 + curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, 60);
2359 + curl_setopt($handle, CURLOPT_DNS_CACHE_TIMEOUT, 300);
2360 + curl_setopt($handle, CURLOPT_TIMEOUT, 300);
2361 + }
2362 +
1537 2363 public function handleAiGenerateImage(): void
1538 2364 {
1539 2365 // Verify nonce and permissions
1540 2366 check_ajax_referer('king_addons_ai_generate_image_nonce', 'nonce');
1541 - if (! current_user_can('manage_options')) {
2367 + if (!current_user_can('manage_options')) {
1542 2368 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
1543 2369 }
1544 2370
1545 2371 // Gather input parameters
1546 - $prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : '';
2372 + $prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : '';
1547 2373 $quality = isset($_POST['quality']) ? sanitize_text_field(wp_unslash($_POST['quality'])) : '';
1548 - $size = isset($_POST['size']) ? sanitize_text_field(wp_unslash($_POST['size'])) : '';
2374 + $size = isset($_POST['size']) ? sanitize_text_field(wp_unslash($_POST['size'])) : '';
1549 2375 // Model from frontend selector
1550 - $model = isset($_POST['model']) ? sanitize_text_field(wp_unslash($_POST['model'])) : 'dall-e-3';
2376 + $model = isset($_POST['model']) ? sanitize_text_field(wp_unslash($_POST['model'])) : '';
2377 + if ($model === '') {
2378 + $model = AI_Provider::getImageModel();
2379 + }
1551 2380
1552 - $options = get_option('king_addons_ai_options', []);
1553 - $api_key = $options['openai_api_key'] ?? '';
2381 + $api_key = AI_Provider::getApiKey();
1554 2382 if (empty($api_key)) {
1555 - wp_send_json_error(['message' => esc_html__('OpenAI API key is not set.', 'king-addons')], 400);
2383 + wp_send_json_error([
2384 + 'message' => sprintf(
2385 + /* translators: %s: provider name */
2386 + esc_html__('%s API key is not set.', 'king-addons'),
2387 + AI_Provider::getLabel()
2388 + )
2389 + ], 400);
1556 2390 }
1557 2391 if (empty($prompt)) {
1558 2392 wp_send_json_error(['message' => esc_html__('Please provide an image prompt.', 'king-addons')], 400);
1559 2393 }
1560 2394
1561 - // Build request body based on selected model
2395 + // Build the request body. OpenAI's image models take vendor specific
2396 + // quality/size/background options; OpenRouter fans out to many vendors
2397 + // that do not share that vocabulary, so only portable fields are sent.
1562 2398 $body = [
1563 - 'model' => $model,
2399 + 'model' => $model,
1564 2400 'prompt' => $prompt,
1565 - 'size' => $size,
1566 2401 ];
1567 - if ($model === 'dall-e-3') {
1568 - // DALL·E 3 parameters
1569 - $body['n'] = 1;
1570 - $body['quality'] = ($quality === 'hd') ? 'hd' : 'standard';
1571 - } elseif ($model === 'gpt-image-1') {
1572 - // GPT Image 1 parameters
1573 - // Only include background when transparent is requested
1574 - if ( ! empty($_POST['background']) && 'transparent' === sanitize_text_field(wp_unslash($_POST['background'])) ) {
1575 - $body['background'] = 'transparent';
2402 +
2403 + if (AI_Provider::isOpenRouter()) {
2404 + $body['n'] = 1;
2405 + } else {
2406 + $body['size'] = $size;
2407 +
2408 + if ($model === 'dall-e-3') {
2409 + // DALL·E 3 parameters
2410 + $body['n'] = 1;
2411 + $body['quality'] = ($quality === 'hd') ? 'hd' : 'standard';
2412 + } elseif ($model === 'gpt-image-1') {
2413 + // GPT Image 1 parameters
2414 + // Only include background when transparent is requested
2415 + if (!empty($_POST['background']) && 'transparent' === sanitize_text_field(wp_unslash($_POST['background']))) {
2416 + $body['background'] = 'transparent';
2417 + }
2418 + $body['quality'] = in_array($quality, ['low', 'medium', 'high', 'auto'], true)
2419 + ? $quality
2420 + : 'auto';
1576 2421 }
1577 - $body['quality'] = in_array($quality, ['low','medium','high','auto'], true)
1578 - ? $quality
1579 - : 'auto';
1580 2422 }
1581 2423
1582 - add_action('http_api_curl', function ($handle, $r) {
1583 - // increase connect timeout to 60s, and total timeout to 5m
1584 - curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, 60);
1585 - curl_setopt($handle, CURLOPT_DNS_CACHE_TIMEOUT, 300);
1586 - curl_setopt($handle, CURLOPT_TIMEOUT, 300);
1587 - }, 10, 2);
2424 + add_action('http_api_curl', [$this, 'set_openai_curl_options'], 10, 3);
1588 2425
1589 - // Call OpenAI Image Generations API
2426 + // Call the provider's image generation API.
1590 2427 $response = wp_remote_post(
1591 - 'https://api.openai.com/v1/images/generations',
2428 + AI_Provider::getImagesEndpoint(),
1592 2429 [
1593 - 'headers' => [
1594 - 'Authorization' => 'Bearer ' . $api_key,
1595 - 'Content-Type' => 'application/json',
1596 - ],
1597 - 'body' => wp_json_encode($body),
2430 + 'headers' => AI_Provider::getHeaders(),
2431 + 'body' => wp_json_encode($body),
1598 2432 'timeout' => 300,
1599 2433 ]
1600 2434 );
1601 - if (is_wp_error($response)) {
1602 - wp_send_json_error(['message' => $response->get_error_message()], 500);
2435 +
2436 + $data = AI_Provider::decodeResponse($response, esc_html__('AI image generation error.', 'king-addons'));
2437 + if (is_wp_error($data)) {
2438 + wp_send_json_error(['message' => $data->get_error_message()], 500);
1603 2439 }
1604 2440
1605 - $image_url = '';
2441 + // Depending on the model, an image comes back either as a hosted URL or
2442 + // as inline base64, so both shapes are accepted from either provider.
2443 + $item = (isset($data['data'][0]) && is_array($data['data'][0])) ? $data['data'][0] : [];
2444 + $base64 = '';
2445 + $remote_url = '';
2446 + $mime = 'image/png';
1606 2447
1607 - if ($model === 'gpt-image-1') {
1608 - // Grab and decode the base64
2448 + if (!empty($item['b64_json']) && is_string($item['b64_json'])) {
2449 + $base64 = $item['b64_json'];
2450 + if (!empty($item['media_type']) && is_string($item['media_type'])) {
2451 + $mime = $item['media_type'];
2452 + }
2453 + } elseif (!empty($item['url']) && is_string($item['url'])) {
2454 + $remote_url = $item['url'];
2455 + } elseif (!empty($item['image_url']['url']) && is_string($item['image_url']['url'])) {
2456 + $remote_url = $item['image_url']['url'];
2457 + }
1609 2458
1610 - $data = json_decode(wp_remote_retrieve_body($response), true);
2459 + // Some providers hand back a data: URI in the url field.
2460 + if ($remote_url !== '' && strpos($remote_url, 'data:') === 0 && preg_match('#^data:([^;,]+);base64,(.+)$#s', $remote_url, $matches)) {
2461 + $mime = $matches[1];
2462 + $base64 = $matches[2];
2463 + $remote_url = '';
2464 + }
1611 2465
1612 - $image_base64 = $data['data'][0]['b64_json'];
2466 + if ($base64 === '' && $remote_url === '') {
2467 + wp_send_json_error([
2468 + 'message' => sprintf(
2469 + /* translators: %s: model id */
2470 + esc_html__('The model %s did not return an image. Pick an image-capable model in AI Settings.', 'king-addons'),
2471 + $model
2472 + )
2473 + ], 500);
2474 + }
1613 2475
1614 - $bytes = base64_decode($image_base64);
1615 - if (! $bytes) {
1616 - wp_send_json_error(['message' => 'Invalid image data from API.'], 500);
1617 - }
2476 + require_once ABSPATH . 'wp-admin/includes/image.php';
2477 + require_once ABSPATH . 'wp-admin/includes/file.php';
2478 + require_once ABSPATH . 'wp-admin/includes/media.php';
1618 2479
1619 - // Create a temp file and write it
1620 - $tmp = wp_tempnam('gpt-image-1.png');
1621 - if (! $tmp || ! file_put_contents($tmp, $bytes)) {
1622 - wp_send_json_error(['message' => 'Failed to write temp image file.'], 500);
2480 + $filename = substr(sanitize_file_name($prompt), 0, 100);
2481 + if ($filename === '') {
2482 + $filename = 'ai-image';
2483 + }
2484 +
2485 + if ($base64 !== '') {
2486 + $bytes = base64_decode($base64, true);
2487 + if (!$bytes) {
2488 + wp_send_json_error(['message' => esc_html__('Invalid image data from API.', 'king-addons')], 500);
1623 2489 }
1624 2490
1625 - // Prepare for sideload
1626 - $file = [
1627 - 'name' => substr(sanitize_file_name($prompt), 0, 100) . '.png',
1628 - 'tmp_name' => $tmp,
1629 - ];
1630 -
1631 - // Make sure these are loaded
1632 - require_once ABSPATH . 'wp-admin/includes/image.php';
1633 - require_once ABSPATH . 'wp-admin/includes/file.php';
1634 - require_once ABSPATH . 'wp-admin/includes/media.php';
1635 -
1636 - // Sideload into the Media Library
1637 - $attach_id = media_handle_sideload($file, 0, $prompt);
1638 - if (is_wp_error($attach_id)) {
1639 - wp_send_json_error(['message' => $attach_id->get_error_message()], 500);
2491 + $extension = 'png';
2492 + $type_map = ['image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif'];
2493 + if (isset($type_map[$mime])) {
2494 + $extension = $type_map[$mime];
1640 2495 }
1641 2496
1642 - $url = wp_get_attachment_url($attach_id);
1643 - wp_send_json_success(['attachment_id' => $attach_id, 'url' => $url]);
1644 - } else {
1645 -
1646 -
1647 - $code = wp_remote_retrieve_response_code($response);
1648 - $data = json_decode(wp_remote_retrieve_body($response), true);
1649 - if ($code !== 200 || empty($data['data'][0]['url'])) {
1650 - $error_msg = $data['error']['message'] ?? esc_html__('AI image generation error.', 'king-addons');
1651 - wp_send_json_error(['message' => $error_msg], 500);
2497 + // Create a temp file and write it
2498 + $tmp = wp_tempnam($filename . '.' . $extension);
2499 + if (!$tmp || !file_put_contents($tmp, $bytes)) {
2500 + wp_send_json_error(['message' => esc_html__('Failed to write temp image file.', 'king-addons')], 500);
1652 2501 }
1653 2502
1654 - // Sideload image into media library
1655 - require_once ABSPATH . 'wp-admin/includes/image.php';
1656 - require_once ABSPATH . 'wp-admin/includes/file.php';
1657 - require_once ABSPATH . 'wp-admin/includes/media.php';
2503 + // Sideload into the Media Library
2504 + $attachment_id = media_handle_sideload(
2505 + ['name' => $filename . '.' . $extension, 'tmp_name' => $tmp],
2506 + 0,
2507 + $prompt
2508 + );
1658 2509
1659 - if ($model === 'dall-e-3') {
1660 - $image_url = esc_url_raw($data['data'][0]['url']);
2510 + if (is_wp_error($attachment_id)) {
2511 + @unlink($tmp);
2512 + wp_send_json_error(['message' => $attachment_id->get_error_message()], 500);
1661 2513 }
1662 -
1663 - $attachment_id = media_sideload_image($image_url, 0, $prompt, 'id');
1664 -
2514 + } else {
2515 + $attachment_id = media_sideload_image(esc_url_raw($remote_url), 0, $prompt, 'id');
1665 2516 if (is_wp_error($attachment_id)) {
1666 2517 wp_send_json_error(['message' => $attachment_id->get_error_message()], 500);
1667 2518 }
1668 - $attachment_url = wp_get_attachment_url($attachment_id);
2519 + }
1669 2520
1670 - // Respond with attachment details
1671 - wp_send_json_success([
1672 - 'attachment_id' => $attachment_id,
1673 - 'url' => $attachment_url,
1674 - ]);
1675 - }
2521 + // Respond with attachment details
2522 + wp_send_json_success([
2523 + 'attachment_id' => $attachment_id,
2524 + 'url' => wp_get_attachment_url($attachment_id),
2525 + ]);
1676 2526 }
1677 2527
1678 2528 /**
2529 + * Renders the global content language field.
2530 + *
2531 + * @return void
2532 + */
2533 + public function renderAiContentLanguageField(): void
2534 + {
2535 + $options = get_option('king_addons_ai_options', []);
2536 + $enabled = !empty($options['content_language_custom_enable']);
2537 + $custom_lang = $options['content_language_custom'] ?? '';
2538 + echo '<label><input type="checkbox" name="king_addons_ai_options[content_language_custom_enable]" value="1" ' . checked($enabled, true, false) . ' id="ka-content-lang-enable-checkbox" /> ' . esc_html__('Generate content in a non-English language', 'king-addons') . '</label>';
2539 + echo '<div id="ka-content-lang-custom-wrap"' . ($enabled ? '' : ' hidden') . ' style="margin-top:8px;">';
2540 + echo '<input type="text" name="king_addons_ai_options[content_language_custom]" value="' . esc_attr($custom_lang) . '" placeholder="' . esc_attr__('language name', 'king-addons') . '" class="regular-text" id="ka-content-lang-custom-input" />';
2541 + echo '<p class="description">' . esc_html__('Applies to all AI-generated content: alt text, tags, blog posts. Type your language name, for example: Spanish, French, German, Polish, Italian, Russian, Hindi, Arabic, Portuguese, etc.', 'king-addons') . '</p>';
2542 + echo '</div>';
2543 + echo '<script>document.getElementById("ka-content-lang-enable-checkbox").addEventListener("change",function(){var w=document.getElementById("ka-content-lang-custom-wrap");if(this.checked){w.removeAttribute("hidden");}else{w.setAttribute("hidden","");}});</script>';
2544 + }
2545 +
2546 + /**
1679 2547 * Renders the Image Detail Level dropdown for Alt Text Settings.
1680 2548 *
1681 2549 * @return void
1682 2550 */
@@ -1719,9 +2587,9 @@
1719 2587 echo '<p class="description">' . esc_html__('When enabled, adds an AI Page Translator button to the Elementor editor top toolbar that allows you to translate entire pages with one click. Automatically detects and translates all text content in widgets including advanced repeater fields.', 'king-addons') . '</p>';
1720 2588 }
1721 2589
1722 2590 /**
1723 - * AJAX handler to translate text using OpenAI.
2591 + * AJAX handler to translate text using the configured AI provider.
1724 2592 *
1725 2593 * @return void
1726 2594 */
1727 2595 public function handleAiTranslateText(): void
@@ -1735,10 +2603,10 @@
1735 2603 $from_lang = isset($_POST['from_lang']) ? sanitize_text_field(wp_unslash($_POST['from_lang'])) : 'auto';
1736 2604 $to_lang = isset($_POST['to_lang']) ? sanitize_text_field(wp_unslash($_POST['to_lang'])) : 'en';
1737 2605
1738 2606 $options = get_option('king_addons_ai_options', []);
1739 - $api_key = $options['openai_api_key'] ?? '';
1740 - $model = $options['openai_model'] ?? '';
2607 + $api_key = AI_Provider::getApiKey();
2608 + $model = AI_Provider::getTextModel();
1741 2609
1742 2610 if (empty($api_key) || empty($model)) {
1743 2611 wp_send_json_error(['message' => esc_html__('API key or model not set.', 'king-addons')], 400);
1744 2612 }
@@ -1752,9 +2620,13 @@
1752 2620 $current_usage = $this->getAiDailyUsage();
1753 2621
1754 2622 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
1755 2623 wp_send_json_error([
1756 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
2624 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
2625 + // This is the plugin's own cap, not the provider's, so the
2626 + // client must not retry it or blame the AI provider.
2627 + 'code' => 'local_limit',
2628 + 'retryable' => false,
1757 2629 ], 429);
1758 2630 }
1759 2631
1760 2632 // Prepare translation prompt
@@ -1759,9 +2631,9 @@
1759 2631
1760 2632 // Prepare translation prompt
1761 2633 $from_lang_name = ($from_lang === 'auto') ? 'auto-detected language' : $this->getLanguageName($from_lang);
1762 2634 $to_lang_name = $this->getLanguageName($to_lang);
1763 -
2635 +
1764 2636 // Enhanced system message for better custom language and prompt handling
1765 2637 $system_message = 'You are a professional translator with expertise in languages, dialects, writing styles, and custom translation approaches. You can handle:
1766 2638
1767 2639 1. Standard languages (English, Spanish, etc.)
@@ -1777,9 +2649,9 @@
1777 2649 - For style prompts, adapt the tone and vocabulary appropriately
1778 2650 - Only return the translated/adapted text without explanations
1779 2651
1780 2652 If the target is a style rather than a language, transform the text to match that style while keeping the same language.';
1781 -
2653 +
1782 2654 // Enhanced user message with better context for custom languages and prompts
1783 2655 if ($from_lang === 'auto') {
1784 2656 $user_message = "Transform the following text to {$to_lang_name}:\n\n{$text}";
1785 2657 } else {
@@ -1784,9 +2656,9 @@
1784 2656 $user_message = "Transform the following text to {$to_lang_name}:\n\n{$text}";
1785 2657 } else {
1786 2658 // Check if it looks like a style prompt rather than a language
1787 2659 $is_style_prompt = $this->isStylePrompt($to_lang_name);
1788 -
2660 +
1789 2661 if ($is_style_prompt) {
1790 2662 $user_message = "Transform the following text from {$from_lang_name} using this style/approach: {$to_lang_name}:\n\n{$text}";
1791 2663 } else {
1792 2664 $user_message = "Translate the following text from {$from_lang_name} to {$to_lang_name}:\n\n{$text}";
@@ -1797,39 +2669,36 @@
1797 2669 ['role' => 'system', 'content' => $system_message],
1798 2670 ['role' => 'user', 'content' => $user_message]
1799 2671 ];
1800 2672
1801 - $response = wp_remote_post(
1802 - 'https://api.openai.com/v1/chat/completions',
1803 - [
1804 - 'headers' => [
1805 - 'Authorization' => 'Bearer ' . $api_key,
1806 - 'Content-Type' => 'application/json',
1807 - ],
1808 - 'body' => wp_json_encode([
1809 - 'model' => $model,
1810 - 'messages' => $messages,
1811 - 'max_tokens' => 1000,
1812 - 'temperature' => 0.3, // Lower temperature for more consistent translations
1813 - ]),
1814 - 'timeout' => 30,
1815 - ]
2673 + $data = AI_Provider::decodeResponse(
2674 + wp_remote_post(
2675 + AI_Provider::getChatEndpoint(),
2676 + [
2677 + 'headers' => AI_Provider::getHeaders(),
2678 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload([
2679 + 'model' => $model,
2680 + 'messages' => $messages,
2681 + 'max_tokens' => 1000,
2682 + 'temperature' => 0.3, // Lower temperature for more consistent translations
2683 + ])),
2684 + 'timeout' => 60, // Free and reasoning models are routinely slower than 30s.
2685 + ]
2686 + ),
2687 + esc_html__('AI translation error.', 'king-addons')
1816 2688 );
1817 2689
1818 - if (is_wp_error($response)) {
1819 - wp_send_json_error(['message' => $response->get_error_message()], 500);
2690 + // The translator runs hundreds of these back to back, so it needs to
2691 + // know whether a failure is worth retrying or is a dead end.
2692 + if (is_wp_error($data)) {
2693 + $this->sendAiProviderError($data);
1820 2694 }
1821 2695
1822 - $code = wp_remote_retrieve_response_code($response);
1823 - $data = json_decode(wp_remote_retrieve_body($response), true);
1824 -
1825 - if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
1826 - $error_msg = $data['error']['message'] ?? esc_html__('AI translation error.', 'king-addons');
1827 - wp_send_json_error(['message' => $error_msg], 500);
2696 + $translated_text = AI_Provider::extractMessageContent($data);
2697 + if (is_wp_error($translated_text)) {
2698 + $this->sendAiProviderError($translated_text);
1828 2699 }
1829 2700
1830 - $translated_text = trim($data['choices'][0]['message']['content']);
1831 -
1832 2701 // Update token usage statistics if present in the response
1833 2702 if (isset($data['usage']['total_tokens'])) {
1834 2703 $this->incrementAiDailyUsage(intval($data['usage']['total_tokens']));
1835 2704 }
@@ -1844,8 +2713,34 @@
1844 2713 ]);
1845 2714 }
1846 2715
1847 2716 /**
2717 + * Ends an AJAX request with a classified provider error.
2718 + *
2719 + * Sends the closest meaningful HTTP status instead of a blanket 500, plus a
2720 + * machine readable code and a retryable flag so the caller can back off and
2721 + * retry transient failures rather than aborting a long run.
2722 + *
2723 + * @param \WP_Error $error Error from the provider layer.
2724 + * @return void
2725 + */
2726 + private function sendAiProviderError(\WP_Error $error): void
2727 + {
2728 + $classified = AI_Provider::classifyError($error);
2729 +
2730 + wp_send_json_error(
2731 + [
2732 + 'message' => $classified['message'],
2733 + 'code' => $classified['code'],
2734 + 'retryable' => $classified['retryable'],
2735 + 'provider' => AI_Provider::getProvider(),
2736 + 'provider_label' => AI_Provider::getLabel(),
2737 + ],
2738 + AI_Provider::getResponseStatus($classified)
2739 + );
2740 + }
2741 +
2742 + /**
1848 2743 * Get language name by code
1849 2744 *
1850 2745 * @param string $code Language code
1851 2746 * @return string Language name
@@ -1887,20 +2782,50 @@
1887 2782 */
1888 2783 private function isStylePrompt(string $text): bool
1889 2784 {
1890 2785 $text_lower = strtolower($text);
1891 -
2786 +
1892 2787 // Common style/tone indicators
1893 2788 $style_indicators = [
1894 - 'formal', 'casual', 'professional', 'business', 'academic', 'technical',
1895 - 'friendly', 'serious', 'humorous', 'dramatic', 'poetic', 'simple',
1896 - 'complex', 'detailed', 'brief', 'conversational', 'literary',
1897 - 'scientific', 'medical', 'legal', 'marketing', 'sales',
1898 - 'tone', 'style', 'manner', 'approach', 'way', 'voice',
1899 - 'pirate', 'shakespeare', 'baby', 'child', 'elderly',
1900 - 'slang', 'jargon', 'dialect', 'accent'
2789 + 'formal',
2790 + 'casual',
2791 + 'professional',
2792 + 'business',
2793 + 'academic',
2794 + 'technical',
2795 + 'friendly',
2796 + 'serious',
2797 + 'humorous',
2798 + 'dramatic',
2799 + 'poetic',
2800 + 'simple',
2801 + 'complex',
2802 + 'detailed',
2803 + 'brief',
2804 + 'conversational',
2805 + 'literary',
2806 + 'scientific',
2807 + 'medical',
2808 + 'legal',
2809 + 'marketing',
2810 + 'sales',
2811 + 'tone',
2812 + 'style',
2813 + 'manner',
2814 + 'approach',
2815 + 'way',
2816 + 'voice',
2817 + 'pirate',
2818 + 'shakespeare',
2819 + 'baby',
2820 + 'child',
2821 + 'elderly',
2822 + 'slang',
2823 + 'jargon',
2824 + 'dialect',
2825 + 'accent'
1901 2826 ];
1902 -
2827 +
1903 2828 // Check if any style indicators are present
1904 2829 foreach ($style_indicators as $indicator) {
1905 2830 if (strpos($text_lower, $indicator) !== false) {
1906 2831 return true;
@@ -1905,20 +2830,29 @@
1905 2830 if (strpos($text_lower, $indicator) !== false) {
1906 2831 return true;
1907 2832 }
1908 2833 }
1909 -
2834 +
1910 2835 // Check if it contains descriptive phrases
1911 2836 $descriptive_patterns = [
1912 - 'for ', 'like ', 'as if ', 'in the style of', 'in a ', 'with a ',
1913 - 'using ', 'speaking ', 'written ', 'sound like', 'talk like'
2837 + 'for ',
2838 + 'like ',
2839 + 'as if ',
2840 + 'in the style of',
2841 + 'in a ',
2842 + 'with a ',
2843 + 'using ',
2844 + 'speaking ',
2845 + 'written ',
2846 + 'sound like',
2847 + 'talk like'
1914 2848 ];
1915 -
2849 +
1916 2850 foreach ($descriptive_patterns as $pattern) {
1917 2851 if (strpos($text_lower, $pattern) !== false) {
1918 2852 return true;
1919 2853 }
1920 2854 }
1921 -
2855 +
1922 2856 return false;
1923 2857 }
1924 2858 }