PluginProbe
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP / trunk
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP vtrunk
3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 3.0.0 2.4.13 2.4.12 2.4.11 2.4.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 110 releases
betterlinks / includes / API / AIBulkLinks.php

AIBulkLinks.php in BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP trunk, at includes/API/AIBulkLinks.php

947 lines 30.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BetterLinks\API;
4 if ( ! defined( 'ABSPATH' ) ) { exit; }
5
6 use BetterLinks\Helper;
7 use BetterLinks\Traits\ArgumentSchema;
8 use BetterLinks\Tools\PromptAnalyzer;
9
10 class AIBulkLinks extends Controller {
11
12 use ArgumentSchema;
13 use \BetterLinks\Traits\Links;
14
15 // phpcs:disable WordPress.DB.DirectDatabaseQuery
16
17 /**
18 * Initialize hooks and option name
19 */
20 public function __construct() {
21 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
22 }
23
24 /**
25 * Register the routes for the objects of the controller.
26 */
27 public function register_routes() {
28 // Get AI Settings
29 register_rest_route(
30 $this->namespace,
31 '/ai-settings',
32 array(
33 array(
34 'methods' => \WP_REST_Server::READABLE,
35 'callback' => array( $this, 'get_ai_settings' ),
36 'permission_callback' => array( $this, 'permissions_check' ),
37 ),
38 )
39 );
40
41 // Update AI Settings (POST and PUT)
42 register_rest_route(
43 $this->namespace,
44 '/ai-settings',
45 array(
46 array(
47 'methods' => \WP_REST_Server::EDITABLE,
48 'callback' => array( $this, 'update_ai_settings' ),
49 'permission_callback' => array( $this, 'permissions_check' ),
50 ),
51 )
52 );
53
54 // Fetch URL content for frontend AI processing
55 register_rest_route(
56 $this->namespace,
57 '/fetch-url-content',
58 array(
59 array(
60 'methods' => \WP_REST_Server::CREATABLE,
61 'callback' => array( $this, 'fetch_url_content_endpoint' ),
62 'permission_callback' => array( $this, 'permissions_check' ),
63 ),
64 )
65 );
66
67 // Process URLs with AI
68 register_rest_route(
69 $this->namespace,
70 '/ai-process-links',
71 array(
72 array(
73 'methods' => \WP_REST_Server::CREATABLE,
74 'callback' => array( $this, 'process_links_with_ai' ),
75 'permission_callback' => array( $this, 'permissions_check' ),
76 ),
77 )
78 );
79
80 // Publish AI Generated Links
81 register_rest_route(
82 $this->namespace,
83 '/ai-publish-links',
84 array(
85 array(
86 'methods' => \WP_REST_Server::CREATABLE,
87 'callback' => array( $this, 'publish_ai_links' ),
88 'permission_callback' => array( $this, 'permissions_check' ),
89 ),
90 )
91 );
92 }
93
94 /**
95 * Get AI Settings
96 */
97 public function get_ai_settings( $request ) {
98 // Get API keys from secure separate option
99 $api_keys = get_option( BETTERLINKS_AI_API_KEYS_OPTION_NAME, array() );
100 if ( is_string( $api_keys ) ) {
101 $api_keys = json_decode( $api_keys, true );
102 }
103
104 // Get AI provider from main settings
105 $all_settings = get_option( BETTERLINKS_LINKS_OPTION_NAME, array() );
106 if ( is_string( $all_settings ) ) {
107 $all_settings = json_decode( $all_settings, true );
108 }
109
110 return new \WP_REST_Response(
111 array(
112 'success' => true,
113 'data' => array(
114 'openai_api_key' => isset( $api_keys['openai_api_key'] ) ? $api_keys['openai_api_key'] : '',
115 'gemini_api_key' => isset( $api_keys['gemini_api_key'] ) ? $api_keys['gemini_api_key'] : '',
116 'ai_provider' => isset( $all_settings['ai_provider'] ) ? $all_settings['ai_provider'] : 'openai',
117 'openai_model' => isset( $all_settings['openai_model'] ) ? $all_settings['openai_model'] : 'gpt-4o-mini',
118 'gemini_model' => isset( $all_settings['gemini_model'] ) ? $all_settings['gemini_model'] : 'gemini-2.5-flash',
119 'openai_token_limit' => isset( $all_settings['openai_token_limit'] ) ? intval( $all_settings['openai_token_limit'] ) : 3000,
120 'gemini_token_limit' => isset( $all_settings['gemini_token_limit'] ) ? intval( $all_settings['gemini_token_limit'] ) : 3000,
121 // AI Link Assistant toggles (default ON when unset).
122 'enable_link_genius' => isset( $all_settings['enable_link_genius'] ) ? (int) $all_settings['enable_link_genius'] : 1,
123 'enable_raw_link_rescue' => isset( $all_settings['enable_raw_link_rescue'] ) ? (int) $all_settings['enable_raw_link_rescue'] : 1,
124 ),
125 ),
126 200
127 );
128 }
129
130 /**
131 * Update AI Settings
132 */
133 public function update_ai_settings( $request ) {
134 $params = $request->get_json_params();
135
136 // If JSON params are empty, try to get from request body
137 if ( empty( $params ) ) {
138 $params = $request->get_params();
139 }
140
141 // Get existing API keys from secure separate option
142 $api_keys = get_option( BETTERLINKS_AI_API_KEYS_OPTION_NAME, array() );
143 if ( is_string( $api_keys ) ) {
144 $api_keys = json_decode( $api_keys, true );
145 }
146
147 // Get existing settings from main betterlinks_links option
148 $all_settings = get_option( BETTERLINKS_LINKS_OPTION_NAME, array() );
149 if ( is_string( $all_settings ) ) {
150 $all_settings = json_decode( $all_settings, true );
151 }
152
153 // Update API keys in separate secure option - validate that API keys are not empty
154 if ( isset( $params['openai_api_key'] ) ) {
155 $openai_key = sanitize_text_field( $params['openai_api_key'] );
156 // Don't allow empty API keys to be saved
157 if ( empty( trim( $openai_key ) ) ) {
158 return new \WP_REST_Response(
159 array(
160 'success' => false,
161 'message' => __( 'OpenAI API key cannot be empty. Please provide a valid API key.', 'betterlinks' ),
162 ),
163 400
164 );
165 }
166 $api_keys['openai_api_key'] = $openai_key;
167 }
168
169 if ( isset( $params['gemini_api_key'] ) ) {
170 $gemini_key = sanitize_text_field( $params['gemini_api_key'] );
171 // Don't allow empty API keys to be saved
172 if ( empty( trim( $gemini_key ) ) ) {
173 return new \WP_REST_Response(
174 array(
175 'success' => false,
176 'message' => __( 'Gemini API key cannot be empty. Please provide a valid API key.', 'betterlinks' ),
177 ),
178 400
179 );
180 }
181 $api_keys['gemini_api_key'] = $gemini_key;
182 }
183
184 // Update AI provider in main settings (non-sensitive)
185 if ( isset( $params['ai_provider'] ) ) {
186 $all_settings['ai_provider'] = sanitize_text_field( $params['ai_provider'] );
187 }
188
189 // Update AI models in main settings (non-sensitive)
190 if ( isset( $params['openai_model'] ) ) {
191 $all_settings['openai_model'] = sanitize_text_field( $params['openai_model'] );
192 }
193
194 if ( isset( $params['gemini_model'] ) ) {
195 $all_settings['gemini_model'] = sanitize_text_field( $params['gemini_model'] );
196 }
197
198 // Update AI token limits in main settings
199 if ( isset( $params['openai_token_limit'] ) ) {
200 $all_settings['openai_token_limit'] = intval( $params['openai_token_limit'] );
201 }
202
203 if ( isset( $params['gemini_token_limit'] ) ) {
204 $all_settings['gemini_token_limit'] = intval( $params['gemini_token_limit'] );
205 }
206
207 // AI Link Assistant feature toggles.
208 if ( isset( $params['enable_link_genius'] ) ) {
209 $all_settings['enable_link_genius'] = (int) (bool) $params['enable_link_genius'];
210 }
211
212 if ( isset( $params['enable_raw_link_rescue'] ) ) {
213 $all_settings['enable_raw_link_rescue'] = (int) (bool) $params['enable_raw_link_rescue'];
214 }
215
216 // Save API keys to secure separate option
217 $api_keys_json = json_encode( $api_keys );
218 if ( $api_keys_json ) {
219 update_option( BETTERLINKS_AI_API_KEYS_OPTION_NAME, $api_keys_json );
220 }
221
222 // Save main settings (without API keys)
223 $all_settings_json = json_encode( $all_settings );
224 if ( $all_settings_json ) {
225 update_option( BETTERLINKS_LINKS_OPTION_NAME, $all_settings_json );
226 // Update cache (API keys are excluded by Cache::write_json_settings)
227 \BetterLinks\Admin\Cache::write_json_settings();
228 }
229
230 return new \WP_REST_Response(
231 array(
232 'success' => true,
233 'message' => __( 'AI settings saved successfully', 'betterlinks' ),
234 'data' => array(
235 'openai_api_key' => isset( $api_keys['openai_api_key'] ) ? $api_keys['openai_api_key'] : '',
236 'gemini_api_key' => isset( $api_keys['gemini_api_key'] ) ? $api_keys['gemini_api_key'] : '',
237 'ai_provider' => isset( $all_settings['ai_provider'] ) ? $all_settings['ai_provider'] : 'openai',
238 'openai_model' => isset( $all_settings['openai_model'] ) ? $all_settings['openai_model'] : 'gpt-4o-mini',
239 'gemini_model' => isset( $all_settings['gemini_model'] ) ? $all_settings['gemini_model'] : 'gemini-2.5-flash',
240 'openai_token_limit' => isset( $all_settings['openai_token_limit'] ) ? intval( $all_settings['openai_token_limit'] ) : 3000,
241 'gemini_token_limit' => isset( $all_settings['gemini_token_limit'] ) ? intval( $all_settings['gemini_token_limit'] ) : 3000,
242 // AI Link Assistant toggles (default ON when unset).
243 'enable_link_genius' => isset( $all_settings['enable_link_genius'] ) ? (int) $all_settings['enable_link_genius'] : 1,
244 'enable_raw_link_rescue' => isset( $all_settings['enable_raw_link_rescue'] ) ? (int) $all_settings['enable_raw_link_rescue'] : 1,
245 ),
246 ),
247 200
248 );
249 }
250
251 /**
252 * Fetch URL content endpoint for frontend AI processing
253 */
254 public function fetch_url_content_endpoint( $request ) {
255 $params = $request->get_json_params();
256 if ( empty( $params ) ) {
257 $params = $request->get_params();
258 }
259
260 $url = isset( $params['url'] ) ? esc_url_raw( $params['url'] ) : '';
261
262 if ( empty( $url ) ) {
263 return new \WP_REST_Response(
264 array(
265 'success' => false,
266 'message' => __( 'URL is required', 'betterlinks' ),
267 ),
268 400
269 );
270 }
271
272 $content = $this->fetch_url_content( $url );
273
274 return new \WP_REST_Response(
275 array(
276 'success' => true,
277 'data' => $content,
278 ),
279 200
280 );
281 }
282
283 /**
284 * Process URLs with AI to generate links
285 */
286 public function process_links_with_ai( $request ) {
287 $params = $request->get_params();
288 $params = isset( $params['params'] ) ? $params['params'] : $params;
289
290 $urls = isset( $params['urls'] ) ? (array) $params['urls'] : array();
291 $prompt = isset( $params['prompt'] ) ? sanitize_text_field( $params['prompt'] ) : '';
292 $options = isset( $params ) ? $params : array();
293
294 if ( empty( $urls ) || empty( $prompt ) ) {
295 return new \WP_REST_Response(
296 array(
297 'success' => false,
298 'message' => __( 'URLs and prompt are required', 'betterlinks' ),
299 ),
300 400
301 );
302 }
303
304 $generated_links = array();
305
306 foreach ( $urls as $url ) {
307 $url = esc_url_raw( $url );
308 if ( empty( $url ) ) {
309 continue;
310 }
311
312 // Fetch content from URL
313 $content = $this->fetch_url_content( $url );
314
315 // Generate link data using AI
316 $link_data = $this->generate_link_with_ai( $url, $content, $prompt, $options );
317
318 if ( $link_data ) {
319 $generated_links[] = $link_data;
320 }
321 }
322
323 return new \WP_REST_Response(
324 array(
325 'success' => true,
326 'data' => $generated_links,
327 ),
328 200
329 );
330 }
331
332 /**
333 * Publish AI Generated Links
334 */
335 public function publish_ai_links( $request ) {
336 // Try to get JSON params first
337 $params = $request->get_json_params();
338 if ( empty( $params ) ) {
339 $params = $request->get_params();
340 }
341
342 // Get links from params
343 $links = isset( $params['links'] ) ? (array) $params['links'] : array();
344
345 if ( empty( $links ) ) {
346 return new \WP_REST_Response(
347 array(
348 'success' => false,
349 'message' => __( 'No links to publish', 'betterlinks' ),
350 ),
351 400
352 );
353 }
354
355 $published_links = array();
356 $current_user_id = get_current_user_id();
357 $current_date = wp_date( 'Y-m-d H:i:s' );
358
359 foreach ( $links as $link_data ) {
360 // Ensure link_data is an array
361 if ( is_object( $link_data ) ) {
362 $link_data = (array) $link_data;
363 }
364
365 // Validate required fields
366 if ( empty( $link_data['short_url'] ) ) {
367 continue;
368 }
369
370 if ( empty( $link_data['target_url'] ) ) {
371 continue;
372 }
373
374 // Add required fields if missing
375 if ( empty( $link_data['link_author'] ) ) {
376 $link_data['link_author'] = $current_user_id;
377 }
378
379 if ( empty( $link_data['link_date'] ) ) {
380 $link_data['link_date'] = $current_date;
381 }
382
383 if ( empty( $link_data['link_date_gmt'] ) ) {
384 $link_data['link_date_gmt'] = $current_date;
385 }
386
387 if ( empty( $link_data['link_modified'] ) ) {
388 $link_data['link_modified'] = $current_date;
389 }
390
391 if ( empty( $link_data['link_modified_gmt'] ) ) {
392 $link_data['link_modified_gmt'] = $current_date;
393 }
394
395 if ( empty( $link_data['link_status'] ) ) {
396 $link_data['link_status'] = 'publish';
397 }
398
399 // Generate link_slug from short_url if not provided
400 if ( empty( $link_data['link_slug'] ) && ! empty( $link_data['short_url'] ) ) {
401 $link_data['link_slug'] = sanitize_title( $link_data['short_url'] );
402 }
403
404 // Create categories if needed
405 if ( ! empty( $link_data['category'] ) ) {
406 $cat_id = Helper::insert_new_category( $link_data['category'] );
407 $link_data['cat_id'] = $cat_id;
408 }
409
410 // Create tags if needed
411 if ( ! empty( $link_data['tags'] ) ) {
412 $tags = is_array( $link_data['tags'] ) ? $link_data['tags'] : array_map( 'trim', explode( ',', $link_data['tags'] ) );
413
414 // Use the insert_tags_terms method which handles creating tags
415 $tag_ids = Helper::insert_tags_terms( $tags );
416
417 if ( ! empty( $tag_ids ) ) {
418 $link_data['tags_id'] = $tag_ids;
419 }
420 }
421
422 // Sanitize the link data
423 $sanitized_data = $this->sanitize_links_data( $link_data );
424
425 // Create the link
426 $result = $this->insert_link( $sanitized_data );
427
428 if ( $result ) {
429 // Save meta tags if customize preview is enabled
430 if ( ! empty( $link_data['enable_customize_preview'] ) && ! empty( $link_data['meta_title'] ) ) {
431 $this->save_meta_tags_for_link( $result['ID'], $link_data );
432 }
433 $published_links[] = $result;
434 }
435 }
436
437 // Clear the cache after publishing all links
438 delete_transient( BETTERLINKS_CACHE_LINKS_NAME );
439
440 // Track AI Link Generator usage
441 if ( ! empty( $published_links ) ) {
442 update_option( 'betterlinks_ai_generator_used', true );
443 }
444
445 return new \WP_REST_Response(
446 array(
447 'success' => true,
448 'data' => $published_links,
449 'message' => sprintf(
450 /* translators: %d: number of AI-generated links that were published. */
451 __( 'Successfully published %d links', 'betterlinks' ),
452 count( $published_links )
453 ),
454 ),
455 200
456 );
457 }
458
459 /**
460 * Fetch URL content
461 */
462 private function fetch_url_content( $url ) {
463 $response = wp_safe_remote_get(
464 $url,
465 array(
466 'timeout' => 10,
467 'sslverify' => false,
468 )
469 );
470
471 if ( is_wp_error( $response ) ) {
472 return '';
473 }
474
475 $body = wp_remote_retrieve_body( $response );
476 $dom = new \DOMDocument();
477 @$dom->loadHTML( $body );
478
479 $title = '';
480 $description = '';
481
482 // Get title
483 $title_tags = $dom->getElementsByTagName( 'title' );
484 if ( $title_tags->length > 0 ) {
485 $title = $title_tags->item( 0 )->textContent;
486 }
487
488 // Get meta description
489 $metas = $dom->getElementsByTagName( 'meta' );
490 foreach ( $metas as $meta ) {
491 if ( $meta->getAttribute( 'name' ) === 'description' ) {
492 $description = $meta->getAttribute( 'content' );
493 break;
494 }
495 }
496
497 return array(
498 'title' => $title,
499 'description' => $description,
500 'url' => $url,
501 );
502 }
503
504 /**
505 * Generate link data with AI
506 */
507 private function generate_link_with_ai( $url, $content, $prompt, $options ) {
508 // Get settings from main betterlinks_links option
509 $all_settings = get_option( BETTERLINKS_LINKS_OPTION_NAME, array() );
510 if ( is_string( $all_settings ) ) {
511 $all_settings = json_decode( $all_settings, true );
512 }
513 $provider = isset( $all_settings['ai_provider'] ) ? $all_settings['ai_provider'] : 'openai';
514
515 // Analyze the prompt to extract constraints
516 $prompt_constraints = PromptAnalyzer::analyze( $prompt );
517
518 // Check if description should be enabled
519 $enable_description = isset( $options['enable_description'] ) ? (bool) $options['enable_description'] : true;
520
521 // For now, return mock data. In production, integrate with OpenAI or Gemini API
522 $title = isset( $content['title'] ) ? $content['title'] : 'Generated Link';
523
524 // Apply title constraints from prompt
525 $title_max_chars = $prompt_constraints['title_max_chars'] ?? ( isset( $options['title_length'] ) ? $options['title_length'] : 60 );
526 $title_max_words = $prompt_constraints['title_max_words'];
527 $title_min_words = $prompt_constraints['title_min_words'];
528 $title = PromptAnalyzer::apply_constraints( $title, $title_max_chars, $title_max_words, $title_min_words );
529
530 $description = '';
531 if ( $enable_description ) {
532 $description = isset( $content['description'] ) ? $content['description'] : '';
533
534 // Apply description constraints from prompt
535 $desc_max_chars = $prompt_constraints['description_max_chars'] ?? ( isset( $options['description_length'] ) ? $options['description_length'] : 160 );
536 $desc_max_words = $prompt_constraints['description_max_words'];
537 $desc_min_words = $prompt_constraints['description_min_words'];
538 $description = PromptAnalyzer::apply_constraints( $description, $desc_max_chars, $desc_max_words, $desc_min_words );
539 }
540
541 // Check if AI category generation is enabled
542 $enable_ai_category = isset( $options['enable_ai_category'] ) ? (bool) $options['enable_ai_category'] : true;
543 $enable_ai_tag = isset( $options['enable_ai_tag'] ) ? (bool) $options['enable_ai_tag'] : true;
544
545 // Check if customize preview (meta tags) should be enabled
546 $enable_customize_preview = isset( $options['enable_customize_preview'] ) ? (bool) $options['enable_customize_preview'] : false;
547
548 // Suggest categories and tags based on URL and content
549 $suggested_data = $this->suggest_categories_and_tags( $url, $title, $description, $enable_ai_category, $enable_ai_tag, $options, $prompt_constraints );
550
551 // Generate meta title and description if customize preview is enabled
552 $meta_title = '';
553 $meta_description = '';
554 if ( $enable_customize_preview ) {
555 $meta_title = $title; // Use the generated title as meta title
556
557 // Apply meta title constraints from prompt
558 if ( ! is_null( $prompt_constraints['meta_title_max_chars'] ) || ! is_null( $prompt_constraints['meta_title_max_words'] ) ) {
559 $meta_title = PromptAnalyzer::apply_constraints(
560 $meta_title,
561 $prompt_constraints['meta_title_max_chars'],
562 $prompt_constraints['meta_title_max_words']
563 );
564 }
565
566 $meta_description = $description; // Use the generated description as meta description
567
568 // Apply meta description constraints from prompt
569 if ( ! is_null( $prompt_constraints['meta_description_max_chars'] ) || ! is_null( $prompt_constraints['meta_description_max_words'] ) ) {
570 $meta_description = PromptAnalyzer::apply_constraints(
571 $meta_description,
572 $prompt_constraints['meta_description_max_chars'],
573 $prompt_constraints['meta_description_max_words']
574 );
575 }
576 }
577
578 return array(
579 'link_title' => $title,
580 'link_note' => $description,
581 'target_url' => $url,
582 'short_url' => $this->generate_short_url( $title, $options ),
583 'redirect_type' => isset( $options['redirect_type'] ) ? $options['redirect_type'] : ( isset( $all_settings['redirect_type'] ) ? $all_settings['redirect_type'] : '307' ),
584 'nofollow' => isset( $all_settings['nofollow'] ) ? $all_settings['nofollow'] : 1,
585 'sponsored' => isset( $all_settings['sponsored'] ) ? $all_settings['sponsored'] : '',
586 'track_me' => isset( $all_settings['track_me'] ) ? $all_settings['track_me'] : 1,
587 'param_forwarding' => isset( $all_settings['param_forwarding'] ) ? $all_settings['param_forwarding'] : '',
588 'category' => $suggested_data['category'],
589 'tags' => $suggested_data['tags'],
590 'cat_id' => $suggested_data['cat_id'],
591 'suggested_tags' => $suggested_data['suggested_tags'],
592 'meta_title' => $meta_title,
593 'meta_description' => $meta_description,
594 'enable_customize_preview' => $enable_customize_preview,
595 );
596 }
597
598 /**
599 * Suggest categories and tags based on URL and content
600 * NOTE: This method only SUGGESTS categories and tags, it does NOT create them.
601 * Categories and tags are created during the publish phase to avoid creating them before publishing.
602 */
603 private function suggest_categories_and_tags( $url, $title, $description, $enable_ai_category = true, $enable_ai_tag = true, $options = array(), $prompt_constraints = array() ) {
604 // Extract domain and path from URL
605 $parsed_url = wp_parse_url( $url );
606 $domain = isset( $parsed_url['host'] ) ? $parsed_url['host'] : '';
607 $path = isset( $parsed_url['path'] ) ? $parsed_url['path'] : '';
608
609 // Extract keywords from domain, path, title, and description
610 $keywords = array();
611
612 // Extract from domain (e.g., github.com -> github)
613 if ( ! empty( $domain ) ) {
614 $domain_parts = explode( '.', str_replace( 'www.', '', $domain ) );
615 if ( ! empty( $domain_parts[0] ) ) {
616 $keywords[] = ucfirst( $domain_parts[0] );
617 }
618 }
619
620 // Extract from path (e.g., /blog/tutorial -> blog, tutorial)
621 if ( ! empty( $path ) ) {
622 $path_parts = array_filter( explode( '/', trim( $path, '/' ) ) );
623 foreach ( $path_parts as $part ) {
624 // Skip common words and numbers
625 if ( strlen( $part ) > 3 && ! is_numeric( $part ) ) {
626 $keywords[] = ucfirst( str_replace( array( '-', '_' ), ' ', $part ) );
627 }
628 }
629 }
630
631 // Extract from title
632 if ( ! empty( $title ) ) {
633 $title_words = explode( ' ', $title );
634 foreach ( $title_words as $word ) {
635 if ( strlen( $word ) > 3 && ! is_numeric( $word ) ) {
636 $keywords[] = $word;
637 }
638 }
639 }
640
641 // Extract from description
642 if ( ! empty( $description ) ) {
643 $desc_words = explode( ' ', $description );
644 foreach ( $desc_words as $word ) {
645 if ( strlen( $word ) > 4 && ! is_numeric( $word ) ) {
646 $keywords[] = $word;
647 // Limit to avoid too many keywords
648 if ( count( $keywords ) >= 10 ) {
649 break;
650 }
651 }
652 }
653 }
654
655 // Remove duplicates and limit to 5 keywords
656 $keywords = array_unique( array_slice( $keywords, 0, 5 ) );
657
658 // Handle category assignment - ONLY SUGGEST, DO NOT CREATE
659 $category_name = 'Uncategorized';
660 $cat_id = 1; // Default to Uncategorized
661
662 // PRIORITY 1: Check if category is specified in prompt constraints (HIGHEST PRIORITY)
663 if ( ! empty( $prompt_constraints['category'] ) ) {
664 // Use category from prompt - this ALWAYS takes priority
665 $category_name = $prompt_constraints['category'];
666 // NOTE: Do NOT create the category here. It will be created during publish phase.
667 }
668 // PRIORITY 2: Use selected category from options
669 elseif ( ! empty( $options['selected_category'] ) ) {
670 $cat_id = intval( $options['selected_category'] );
671 // Get category name from ID
672 $category_term = Helper::get_term_by_id( $cat_id, 'category' );
673 if ( ! empty( $category_term ) && is_array( $category_term ) ) {
674 $category_name = $category_term[0]['term_name'];
675 }
676 }
677 // PRIORITY 3: AI-generated category from keywords (if enabled)
678 elseif ( $enable_ai_category ) {
679 // AI-generated category from keywords
680 $category_name = ! empty( $keywords ) ? $keywords[0] : 'Uncategorized';
681 // NOTE: Do NOT create the category here. It will be created during publish phase.
682 }
683
684 // Handle tag assignment
685 // NOTE: Tags are only SUGGESTED here, they will be created during publish phase
686 // CHANGE: Now supports multiple tags when user specifies them in prompt
687 $tags_string = '';
688
689 // PRIORITY 1: Check if tags are specified in prompt constraints (HIGHEST PRIORITY)
690 if ( ! empty( $prompt_constraints['tags'] ) && is_array( $prompt_constraints['tags'] ) && count( $prompt_constraints['tags'] ) > 0 ) {
691 // Use ALL tags from prompt (if user specifies multiple tags, use all of them)
692 // Convert array to comma-separated string for storage
693 $tags_string = implode( ',', $prompt_constraints['tags'] );
694 }
695 // PRIORITY 2: Use selected tags from options
696 elseif ( ! empty( $options['selected_tags'] ) ) {
697 $selected_tags = (array) $options['selected_tags'];
698 // Get all selected tags
699 if ( ! empty( $selected_tags ) ) {
700 $tag_names = array();
701 foreach ( $selected_tags as $tag_id ) {
702 $tag_term = Helper::get_term_by_id( $tag_id, 'tags' );
703 if ( ! empty( $tag_term ) && is_array( $tag_term ) ) {
704 $tag_names[] = $tag_term[0]['term_name'];
705 }
706 }
707 if ( ! empty( $tag_names ) ) {
708 $tags_string = implode( ',', $tag_names );
709 }
710 }
711 }
712 // PRIORITY 3: AI-generated tag (if enabled)
713 elseif ( $enable_ai_tag ) {
714 // AI-generated tag - use only the FIRST keyword as tag (1 tag per link by default)
715 if ( ! empty( $keywords ) && count( $keywords ) > 1 ) {
716 // Use second keyword as tag (first is category)
717 $tags_string = $keywords[1];
718 }
719 }
720
721 return array(
722 'category' => $category_name,
723 'cat_id' => $cat_id,
724 'tags' => $tags_string,
725 'suggested_tags' => $tags,
726 );
727 }
728
729 /**
730 * Generate short URL
731 */
732 private function generate_short_url( $title, $options ) {
733 // Get settings from main betterlinks_links option
734 $all_settings = get_option( BETTERLINKS_LINKS_OPTION_NAME, array() );
735 if ( is_string( $all_settings ) ) {
736 $all_settings = json_decode( $all_settings, true );
737 }
738
739 // Get prefix from settings (default: 'go')
740 $prefix = isset( $all_settings['prefix'] ) ? $all_settings['prefix'] : 'go';
741 $prefix = ! empty( $prefix ) ? trailingslashit( $prefix ) : '';
742
743 $strategy = isset( $options['short_url_strategy'] ) ? $options['short_url_strategy'] : 'from_title';
744
745 switch ( $strategy ) {
746 case 'from_title':
747 $slug = $this->generate_short_slug_from_title( $title );
748 break;
749 case 'random':
750 $helper = new Helper();
751 $slug = $helper->generate_random_slug();
752 break;
753 case 'custom_prefix':
754 $slug = 'link-' . wp_generate_password( 6, false );
755 break;
756 default:
757 $helper = new Helper();
758 $slug = $helper->generate_random_slug();
759 }
760
761 // Combine prefix with slug
762 $short_url = $prefix . $slug;
763
764 // Check if short URL already exists and make it unique
765 $short_url = $this->ensure_unique_short_url( $short_url, $title );
766
767 return $short_url;
768 }
769
770 /**
771 * Generate a short slug from title (1-3 words, max 30 characters)
772 */
773 private function generate_short_slug_from_title( $title ) {
774 // Remove common words and clean up
775 $common_words = array( 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being' );
776
777 // Sanitize and split into words
778 $title = sanitize_title( $title );
779 $words = array_filter( explode( '-', $title ) );
780
781 // Filter out common words
782 $filtered_words = array_filter( $words, function( $word ) use ( $common_words ) {
783 return ! in_array( $word, $common_words ) && strlen( $word ) > 2;
784 });
785
786 // Reset array keys
787 $filtered_words = array_values( $filtered_words );
788
789 // Build slug with 1-3 words
790 $slug_parts = array();
791 $total_length = 0;
792 $max_length = 30;
793 $max_words = 2;
794
795 foreach ( $filtered_words as $word ) {
796 // If word is too long (> 12 chars), use only first word
797 if ( strlen( $word ) > 12 ) {
798 if ( empty( $slug_parts ) ) {
799 $slug_parts[] = substr( $word, 0, 12 );
800 }
801 break;
802 }
803
804 // Check if adding this word would exceed limits
805 $potential_length = $total_length + strlen( $word ) + ( ! empty( $slug_parts ) ? 1 : 0 );
806 if ( $potential_length > $max_length || count( $slug_parts ) >= $max_words ) {
807 break;
808 }
809
810 $slug_parts[] = $word;
811 $total_length = $potential_length;
812 }
813
814 // If no words found, use first 8 characters of sanitized title
815 if ( empty( $slug_parts ) ) {
816 $slug_parts[] = substr( $title, 0, 8 );
817 }
818
819 return implode( '-', $slug_parts );
820 }
821
822 /**
823 * Ensure short URL is unique, generate alternatives if needed
824 */
825 private function ensure_unique_short_url( $short_url, $title ) {
826 // Check if short URL already exists
827 if ( ! Helper::is_exists_short_url( $short_url ) ) {
828 return $short_url;
829 }
830
831 // Short URL exists, try to make it unique
832 $prefix = '';
833 $slug = $short_url;
834
835 // Extract prefix if it exists
836 if ( strpos( $short_url, '/' ) !== false ) {
837 $parts = explode( '/', $short_url );
838 $prefix = $parts[0] . '/';
839 $slug = $parts[1];
840 }
841
842 // Strategy 1: Try adding numeric suffix (1, 2, 3, etc.)
843 for ( $i = 1; $i <= 100; $i++ ) {
844 $new_short_url = $prefix . $slug . '-' . $i;
845 if ( ! Helper::is_exists_short_url( $new_short_url ) ) {
846 return $new_short_url;
847 }
848 }
849
850 // Strategy 2: Try using first word only
851 $words = explode( '-', $slug );
852 if ( count( $words ) > 1 ) {
853 $first_word = $words[0];
854 for ( $i = 1; $i <= 100; $i++ ) {
855 $new_short_url = $prefix . $first_word . '-' . $i;
856 if ( ! Helper::is_exists_short_url( $new_short_url ) ) {
857 return $new_short_url;
858 }
859 }
860 }
861
862 // Strategy 3: Use random suffix
863 for ( $i = 0; $i < 10; $i++ ) {
864 $random_suffix = wp_generate_password( 4, false );
865 $new_short_url = $prefix . $slug . '-' . $random_suffix;
866 if ( ! Helper::is_exists_short_url( $new_short_url ) ) {
867 return $new_short_url;
868 }
869 }
870
871 // Strategy 4: Generate completely random slug
872 for ( $i = 0; $i < 10; $i++ ) {
873 $random_slug = Helper::generateSlug();
874 $new_short_url = $prefix . $random_slug;
875 if ( ! Helper::is_exists_short_url( $new_short_url ) ) {
876 return $new_short_url;
877 }
878 }
879
880 // Fallback: Use timestamp-based slug
881 $timestamp_slug = 'link-' . time();
882 return $prefix . $timestamp_slug;
883 }
884
885 /**
886 * Save meta tags for a link
887 */
888 private function save_meta_tags_for_link( $link_id, $link_data ) {
889 global $wpdb;
890
891 $meta_title = isset( $link_data['meta_title'] ) ? sanitize_text_field( $link_data['meta_title'] ) : '';
892 $meta_description = isset( $link_data['meta_description'] ) ? sanitize_text_field( $link_data['meta_description'] ) : '';
893
894 if ( empty( $meta_title ) ) {
895 return false;
896 }
897
898 // Check if meta tags already exist for this link
899 $existing = $wpdb->get_results(
900 $wpdb->prepare(
901 "SELECT * FROM {$wpdb->prefix}betterlinks_meta_tags WHERE link_id = %d",
902 $link_id
903 )
904 );
905
906 if ( ! empty( $existing ) ) {
907 // Update existing meta tags
908 $wpdb->query(
909 $wpdb->prepare(
910 "UPDATE {$wpdb->prefix}betterlinks_meta_tags SET meta_title = %s, meta_desc = %s, status = 1 WHERE link_id = %d",
911 $meta_title,
912 $meta_description,
913 $link_id
914 )
915 );
916 } else {
917 // Insert new meta tags
918 $wpdb->query(
919 $wpdb->prepare(
920 "INSERT INTO {$wpdb->prefix}betterlinks_meta_tags (link_id, meta_title, meta_desc, status) VALUES (%d, %s, %s, 1)",
921 $link_id,
922 $meta_title,
923 $meta_description
924 )
925 );
926 }
927
928 return true;
929 }
930
931 /**
932 * Check if a given request has access to manage AI settings
933 */
934 public function permissions_check( $request ) {
935 return apply_filters( 'betterlinks/api/ai_permissions_check', current_user_can( 'manage_options' ) );
936 }
937
938 /**
939 * Stub methods required by abstract class
940 */
941 protected function get_items( $request ) {}
942 protected function create_item( $request ) {}
943 protected function update_item( $request ) {}
944 protected function delete_item( $request ) {}
945 }
946
947