| @@ -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 | } |