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 / Admin / ShortLinkGenerator.php

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

1,887 lines 69.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace BetterLinks\Admin;
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 use BetterLinks\Helper;
6
7 class ShortLinkGenerator
8 {
9 use \BetterLinks\Traits\Links;
10 use \BetterLinks\Traits\Terms;
11 use \BetterLinks\Traits\ArgumentSchema;
12
13 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB
14
15 private static $instance = null;
16
17 public static function getInstance()
18 {
19 if (self::$instance === null) {
20 self::$instance = new self();
21 }
22 return self::$instance;
23 }
24
25 public function __construct()
26 {
27 add_action('wp_ajax_betterlinks/admin/get_post_types_with_taxonomies', [$this, 'get_post_types_with_taxonomies']);
28 add_action('wp_ajax_betterlinks/admin/get_posts_count', [$this, 'get_posts_count']);
29 add_action('wp_ajax_betterlinks/admin/start_bulk_generation', [$this, 'start_bulk_generation']);
30 add_action('wp_ajax_betterlinks/admin/get_generation_progress', [$this, 'get_generation_progress']);
31 add_action('wp_ajax_betterlinks/admin/pause_bulk_generation', [$this, 'pause_bulk_generation']);
32 add_action('wp_ajax_betterlinks/admin/resume_bulk_generation', [$this, 'resume_bulk_generation']);
33 add_action('wp_ajax_betterlinks/admin/cancel_bulk_generation', [$this, 'cancel_bulk_generation']);
34 add_action('wp_ajax_betterlinks/admin/download_generation_report', [$this, 'download_generation_report']);
35 }
36
37 /**
38 * Verify if user has pro access
39 * @return bool
40 */
41 private function verify_pro_access()
42 {
43 return $this->is_pro_enabled() && $this->is_pro_plugin_active() && $this->check_pro_license() && $this->is_pro_bulk_generator_available();
44 }
45
46 /**
47 * Check if pro is enabled via filter
48 * @return bool
49 */
50 private function is_pro_enabled()
51 {
52 return apply_filters('betterlinks/pro_enabled', false);
53 }
54
55 /**
56 * Check if pro plugin is physically active
57 * @return bool
58 */
59 private function is_pro_plugin_active()
60 {
61 return defined('BETTERLINKS_PRO_VERSION') ||
62 is_plugin_active('betterlinks-pro/betterlinks-pro.php') ||
63 class_exists('BetterLinksPro');
64 }
65
66 /**
67 * Additional license checks for pro features
68 * @return bool
69 */
70 private function check_pro_license()
71 {
72 // If pro version is defined, assume license is valid
73 // This can be enhanced with actual license verification
74 return defined('BETTERLINKS_PRO_VERSION');
75 }
76
77 /**
78 * Check if the Pro plugin has bulk link generator feature available
79 * This ensures old Pro versions without this feature cannot access it
80 *
81 * @since 1.7.0
82 * @return bool
83 */
84 private function is_pro_bulk_generator_available()
85 {
86 // Check if the Pro Helper class exists and has the bulk generator method
87 return class_exists('BetterLinksPro\\Helper') &&
88 method_exists('BetterLinksPro\\Helper', 'is_bulk_link_generator_enabled') &&
89 \BetterLinksPro\Helper::is_bulk_link_generator_enabled();
90 }
91
92 /**
93 * Get all post types with their associated taxonomies
94 */
95 public function get_post_types_with_taxonomies()
96 {
97 try {
98 check_ajax_referer('betterlinks_admin_nonce', 'security');
99
100 if (!current_user_can('manage_options')) {
101 wp_die("You don't have permission to do this.");
102 }
103
104 // Get post types that are publicly queryable or have UI
105 $post_types = get_post_types([
106 'publicly_queryable' => true
107 ], 'objects');
108
109 // Also include post types that have UI but may not be publicly queryable
110 $ui_post_types = get_post_types([
111 'show_ui' => true
112 ], 'objects');
113
114 // Merge and remove duplicates
115 $post_types = array_merge($post_types, $ui_post_types);
116 $post_types = array_unique($post_types, SORT_REGULAR);
117
118 // Filter out attachment and other unwanted types
119 $excluded_types = ['attachment', 'revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation'];
120 $post_types = array_filter($post_types, function($post_type) use ($excluded_types) {
121 return !in_array($post_type->name, $excluded_types);
122 });
123
124 $result = [];
125
126 foreach ($post_types as $post_type) {
127 $taxonomies = get_object_taxonomies($post_type->name, 'objects');
128 $categories = [];
129 $tags = [];
130
131 foreach ($taxonomies as $taxonomy) {
132 if ($taxonomy->hierarchical) {
133 // This is likely a category-type taxonomy
134 $terms = get_terms([
135 'taxonomy' => $taxonomy->name,
136 'hide_empty' => false,
137 'orderby' => 'name',
138 'order' => 'ASC'
139 ]);
140
141 if (!is_wp_error($terms)) {
142 $categories[$taxonomy->name] = [
143 'label' => $taxonomy->label,
144 'terms' => array_map(function($term) {
145 return [
146 'id' => $term->term_id,
147 'name' => $term->name,
148 'slug' => $term->slug,
149 'count' => $term->count
150 ];
151 }, $terms)
152 ];
153 }
154 } else {
155 // This is likely a tag-type taxonomy
156 $terms = get_terms([
157 'taxonomy' => $taxonomy->name,
158 'hide_empty' => false,
159 'orderby' => 'name',
160 'order' => 'ASC'
161 ]);
162
163 if (!is_wp_error($terms)) {
164 $tags[$taxonomy->name] = [
165 'label' => $taxonomy->label,
166 'terms' => array_map(function($term) {
167 return [
168 'id' => $term->term_id,
169 'name' => $term->name,
170 'slug' => $term->slug,
171 'count' => $term->count
172 ];
173 }, $terms)
174 ];
175 }
176 }
177 }
178
179 $result[] = [
180 'name' => $post_type->name,
181 'label' => $post_type->label,
182 'categories' => $categories,
183 'tags' => $tags
184 ];
185 }
186
187 wp_send_json_success($result);
188 } catch (Exception $e) {
189 wp_send_json_error([
190 'message' => __('Error loading post types: ', 'betterlinks') . $e->getMessage()
191 ]);
192 }
193 }
194
195 /**
196 * Get count of posts based on filters
197 */
198 public function get_posts_count()
199 {
200 try {
201 check_ajax_referer('betterlinks_admin_nonce', 'security');
202
203 if (!current_user_can('manage_options')) {
204 wp_send_json_error(['message' => __('You don\'t have permission to do this.', 'betterlinks')]);
205 return;
206 }
207
208 // PRO FEATURE CHECK - First priority security
209 if (!$this->verify_pro_access()) {
210 wp_send_json_error([
211 'message' => __('This feature requires BetterLinks Pro.', 'betterlinks'),
212 'code' => 'pro_required'
213 ], 403);
214 return;
215 }
216
217 $post_type = isset($_POST['post_type']) ? sanitize_text_field(wp_unslash( $_POST['post_type'] )) : '';
218
219 // Handle categories - they might come as JSON string from FormData. Values are coerced to int below; sanitize_text_field would corrupt JSON.
220 $categories = [];
221 if (isset($_POST['categories'])) {
222 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
223 $raw_categories = wp_unslash( $_POST['categories'] );
224 if (is_string($raw_categories)) {
225 $decoded_categories = json_decode($raw_categories, true);
226 $categories = is_array($decoded_categories) ? array_map('intval', $decoded_categories) : [];
227 } else if (is_array($raw_categories)) {
228 $categories = array_map('intval', $raw_categories);
229 }
230 }
231
232 // Handle tags - they might come as JSON string from FormData. Values are coerced to int below; sanitize_text_field would corrupt JSON.
233 $tags = [];
234 if (isset($_POST['tags'])) {
235 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
236 $raw_tags = wp_unslash( $_POST['tags'] );
237 if (is_string($raw_tags)) {
238 $decoded_tags = json_decode($raw_tags, true);
239 $tags = is_array($decoded_tags) ? array_map('intval', $decoded_tags) : [];
240 } else if (is_array($raw_tags)) {
241 $tags = array_map('intval', $raw_tags);
242 }
243 }
244
245 $include_existing = isset($_POST['include_existing']) ? (bool)$_POST['include_existing'] : false;
246
247 if (empty($post_type)) {
248 wp_send_json_error(['message' => __('Post type is required.', 'betterlinks')]);
249 return;
250 }
251
252 // Build basic query args
253 $args = [
254 'post_type' => $post_type,
255 'post_status' => 'publish',
256 'posts_per_page' => -1,
257 'fields' => 'ids'
258 ];
259
260
261 // Only add tax_query if we have categories or tags // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
262 if (!empty($categories) || !empty($tags)) {
263 $args['tax_query'] = ['relation' => 'AND'];
264
265 // Add category filter if provided
266 if (!empty($categories)) {
267 $category_taxonomies = get_object_taxonomies($post_type, 'objects');
268
269
270 $taxonomy_added = false;
271
272 // First, try to find which taxonomy these category IDs actually belong to
273 foreach ($categories as $cat_id) {
274 foreach ($category_taxonomies as $taxonomy) {
275 if ($taxonomy->hierarchical) {
276 $term = get_term($cat_id, $taxonomy->name);
277 if (!is_wp_error($term) && $term) {
278 $args['tax_query'][] = [
279 'taxonomy' => $taxonomy->name,
280 'field' => 'term_id',
281 'terms' => $categories,
282 'operator' => 'IN'
283 ];
284 $taxonomy_added = true;
285 break 2; // Break both loops
286 }
287 }
288 }
289 }
290
291 // Fallback: use first hierarchical taxonomy if no specific match found
292 if (!$taxonomy_added) {
293 foreach ($category_taxonomies as $taxonomy) {
294 if ($taxonomy->hierarchical) {
295 $args['tax_query'][] = [
296 'taxonomy' => $taxonomy->name,
297 'field' => 'term_id',
298 'terms' => $categories,
299 'operator' => 'IN'
300 ];
301 $taxonomy_added = true;
302 break;
303 }
304 }
305 }
306
307 // If no hierarchical taxonomy found, try to find any category-like taxonomy
308 if (!$taxonomy_added && !empty($category_taxonomies)) {
309 $first_taxonomy = reset($category_taxonomies);
310 $args['tax_query'][] = [
311 'taxonomy' => $first_taxonomy->name,
312 'field' => 'term_id',
313 'terms' => $categories,
314 'operator' => 'IN'
315 ];
316 }
317 }
318
319 // Add tags filter if provided
320 if (!empty($tags)) {
321 $tag_taxonomies = get_object_taxonomies($post_type, 'objects');
322 foreach ($tag_taxonomies as $taxonomy) {
323 if (!$taxonomy->hierarchical) {
324 $args['tax_query'][] = [
325 'taxonomy' => $taxonomy->name,
326 'field' => 'term_id',
327 'terms' => $tags,
328 'operator' => 'IN'
329 ];
330 break; // Use first non-hierarchical taxonomy found
331 }
332 }
333 }
334 }
335
336 // Debug final query args
337
338 // Execute the query
339 $query = new \WP_Query($args);
340
341 // Debug query results
342 if ($query->found_posts > 0) {
343 // PCP-DEBUG-DISABLED: error_log('Sample post IDs: ' . print_r(array_slice($query->posts, 0, 5), true));
344 }
345
346 if (is_wp_error($query)) {
347 wp_send_json_error(['message' => __('Query error: ', 'betterlinks') . $query->get_error_message()]);
348 return;
349 }
350
351 $total_posts = $query->found_posts;
352
353 // If not including existing BetterLinks, subtract posts that already have short links
354 if (!$include_existing && $total_posts > 0) {
355 $existing_count = $this->count_posts_with_existing_links($query->posts);
356 $total_posts -= $existing_count;
357 }
358
359 $result = [
360 'count' => max(0, $total_posts),
361 /* translators: %s = placeholder values supplied by WordPress */
362 'message' => sprintf(__('Found %d posts matching your criteria.', 'betterlinks'), max(0, $total_posts))
363 ];
364
365 wp_send_json_success($result);
366
367 } catch (Exception $e) {
368 wp_send_json_error([
369 'message' => __('Error counting posts: ', 'betterlinks') . $e->getMessage()
370 ]);
371 } catch (Error $e) {
372 wp_send_json_error([
373 'message' => __('Fatal error counting posts. Please check server logs.', 'betterlinks')
374 ]);
375 }
376 }
377
378 /**
379 * Count posts that already have BetterLinks
380 */
381 private function count_posts_with_existing_links($post_ids)
382 {
383 if (empty($post_ids)) {
384 return 0;
385 }
386
387 try {
388 global $wpdb;
389 $count = 0;
390
391 // Check each post individually to avoid complex SQL issues
392 foreach ($post_ids as $post_id) {
393 $permalink = get_permalink($post_id);
394 if ($permalink) {
395 $existing = $wpdb->get_var($wpdb->prepare("
396 SELECT COUNT(*)
397 FROM {$wpdb->prefix}betterlinks
398 WHERE target_url = %s
399 ", $permalink));
400
401 if ($existing > 0) {
402 $count++;
403 }
404 }
405 }
406
407 return $count;
408 } catch (Exception $e) {
409 // If there's an error, assume no existing links to be safe
410 return 0;
411 }
412 }
413
414 /**
415 * Check if a link already exists for a target URL
416 */
417 private function link_exists_for_target_url($target_url)
418 {
419 if (empty($target_url)) {
420 return false;
421 }
422
423 try {
424 global $wpdb;
425 $count = $wpdb->get_var($wpdb->prepare("
426 SELECT COUNT(*)
427 FROM {$wpdb->prefix}betterlinks
428 WHERE target_url = %s
429 ", $target_url));
430
431 return $count > 0;
432 } catch (Exception $e) {
433 // If there's an error, assume no existing link to be safe
434 return false;
435 }
436 }
437
438 /**
439 * Delete existing link for a target URL
440 */
441 private function delete_existing_link_for_target_url($target_url)
442 {
443 if (empty($target_url)) {
444 return false;
445 }
446
447 try {
448 global $wpdb;
449 $link_id = $wpdb->get_var($wpdb->prepare("
450 SELECT ID
451 FROM {$wpdb->prefix}betterlinks
452 WHERE target_url = %s
453 LIMIT 1
454 ", $target_url));
455
456 if ($link_id) {
457 // Delete the link
458 $result = $wpdb->delete(
459 $wpdb->prefix . 'betterlinks',
460 ['ID' => $link_id],
461 ['%d']
462 );
463
464 // Clear cache after deletion
465 $helper = new Helper();
466 $helper->clear_query_cache();
467
468 return $result !== false;
469 }
470
471 return false;
472 } catch (Exception $e) {
473 // PCP-DEBUG-DISABLED: error_log('BetterLinks: Error deleting existing link: ' . $e->getMessage());
474 return false;
475 }
476 }
477
478 /**
479 * Start bulk generation process
480 */
481 public function start_bulk_generation()
482 {
483 check_ajax_referer('betterlinks_admin_nonce', 'security');
484
485 if (!current_user_can('manage_options')) {
486 wp_send_json_error(['message' => __('You don\'t have permission to do this.', 'betterlinks')]);
487 return;
488 }
489
490 // PRO FEATURE CHECK - First priority security
491 if (!$this->verify_pro_access()) {
492 wp_send_json_error([
493 'message' => __('This feature requires BetterLinks Pro.', 'betterlinks'),
494 'code' => 'pro_required'
495 ], 403);
496 return;
497 }
498
499 try {
500 // Sanitize and validate filters
501 $filters = $this->sanitize_generation_filters($_POST);
502
503 if (is_wp_error($filters)) {
504 wp_send_json_error(['message' => $filters->get_error_message()]);
505 return;
506 }
507
508 // Get posts to generate links for
509 $args = $this->build_query_args($filters);
510
511 $query = new \WP_Query($args);
512 $post_ids = $query->posts;
513
514
515 if (empty($post_ids)) {
516 wp_send_json_error(['message' => __('No posts found to generate links for.', 'betterlinks')]);
517 return;
518 }
519
520 // Filter out posts with existing links if not including them
521 $original_post_count = count($post_ids);
522 if (!$filters['include_existing']) {
523 $post_ids = $this->filter_posts_without_existing_links($post_ids);
524 }
525
526 if (empty($post_ids)) {
527 // All posts already have links - show completed state instead of error
528 update_option('betterlinks_bulk_generation_status', [
529 'status' => 'completed',
530 'started_at' => current_time('mysql'),
531 'completed_at' => current_time('mysql'),
532 'total' => $original_post_count,
533 'processed' => $original_post_count,
534 'successful' => 0,
535 'failed' => 0,
536 'skipped' => $original_post_count,
537 'progress_percent' => 100,
538 'errors' => []
539 ]);
540
541 // Store empty report data
542 update_option('betterlinks_bulk_generation_report', []);
543
544 wp_send_json_success([
545 /* translators: %s = placeholder values supplied by WordPress */
546 'message' => sprintf(__('All %d posts already have short links.', 'betterlinks'), $original_post_count),
547 'queued' => 0,
548 'successful' => 0,
549 'failed' => 0,
550 'skipped' => $original_post_count,
551 'all_exist' => true
552 ]);
553 return;
554 }
555
556 // Initialize generation status
557 update_option('betterlinks_bulk_generation_status', [
558 'status' => 'running',
559 'started_at' => current_time('mysql'),
560 'total' => count($post_ids),
561 'processed' => 0,
562 'successful' => 0,
563 'failed' => 0,
564 'skipped' => 0,
565 'progress_percent' => 0,
566 'errors' => []
567 ]);
568
569 // Generate short links for each post
570 $successful = 0;
571 $failed = 0;
572 $skipped = 0;
573 $errors = [];
574 $report_data = [];
575
576 foreach ($post_ids as $index => $post_id) {
577 try {
578 $result = $this->create_short_link_for_post($post_id, $filters);
579
580 if ($result['success']) {
581 $successful++;
582 $report_data[] = [
583 'post_id' => $post_id,
584 'post_title' => get_the_title($post_id),
585 'post_url' => get_permalink($post_id),
586 'short_url' => $result['short_url'],
587 'link_id' => $result['link_id'],
588 'status' => 'success',
589 'error' => '',
590 'category' => $this->get_post_categories_string($post_id),
591 'tags' => $this->get_post_tags_string($post_id),
592 'created_at' => current_time('mysql')
593 ];
594 } else if (!empty($result['skipped'])) {
595 // Link already exists, skip this post
596 $skipped++;
597 $report_data[] = [
598 'post_id' => $post_id,
599 'post_title' => get_the_title($post_id),
600 'post_url' => get_permalink($post_id),
601 'short_url' => '',
602 'link_id' => '',
603 'status' => 'skipped',
604 'error' => $result['message'],
605 'category' => $this->get_post_categories_string($post_id),
606 'tags' => $this->get_post_tags_string($post_id),
607 'created_at' => current_time('mysql')
608 ];
609 } else {
610 $failed++;
611 /* translators: 1: post ID, 2: error message returned from the link creator. */
612 $errors[] = sprintf(__('Post ID %1$d: %2$s', 'betterlinks'), $post_id, $result['message']);
613 $report_data[] = [
614 'post_id' => $post_id,
615 'post_title' => get_the_title($post_id),
616 'post_url' => get_permalink($post_id),
617 'short_url' => '',
618 'link_id' => '',
619 'status' => 'failed',
620 'error' => $result['message'],
621 'category' => $this->get_post_categories_string($post_id),
622 'tags' => $this->get_post_tags_string($post_id),
623 'created_at' => current_time('mysql')
624 ];
625 }
626
627 // Update progress
628 $processed = $index + 1;
629 $progress_percent = round(($processed / count($post_ids)) * 100, 2);
630
631 update_option('betterlinks_bulk_generation_status', [
632 'status' => 'running',
633 'started_at' => get_option('betterlinks_bulk_generation_status')['started_at'],
634 'total' => count($post_ids),
635 'processed' => $processed,
636 'successful' => $successful,
637 'failed' => $failed,
638 'skipped' => $skipped,
639 'progress_percent' => $progress_percent,
640 'errors' => $errors
641 ]);
642
643 } catch (Exception $e) {
644 $failed++;
645 $error_msg = 'Post ID ' . $post_id . ': ' . $e->getMessage();
646 $errors[] = $error_msg;
647
648 $report_data[] = [
649 'post_id' => $post_id,
650 'post_title' => get_the_title($post_id),
651 'post_url' => get_permalink($post_id),
652 'short_url' => '',
653 'link_id' => '',
654 'status' => 'failed',
655 'error' => $e->getMessage(),
656 'category' => $this->get_post_categories_string($post_id),
657 'tags' => $this->get_post_tags_string($post_id),
658 'created_at' => current_time('mysql')
659 ];
660 }
661 }
662
663 // Store final status
664 update_option('betterlinks_bulk_generation_status', [
665 'status' => 'completed',
666 'started_at' => get_option('betterlinks_bulk_generation_status')['started_at'],
667 'completed_at' => current_time('mysql'),
668 'total' => count($post_ids),
669 'processed' => count($post_ids),
670 'successful' => $successful,
671 'failed' => $failed,
672 'skipped' => $skipped,
673 'progress_percent' => 100,
674 'errors' => $errors
675 ]);
676
677 // Store report data
678 update_option('betterlinks_bulk_generation_report', $report_data);
679
680 // Clear cache after bulk generation completes so new links are fetched
681 $helper = new Helper();
682 $helper->clear_query_cache();
683
684 wp_send_json_success([
685 /* translators: 1: count of links successfully generated, 2: count failed, 3: count skipped because they already exist. */
686 'message' => sprintf(__('Generated %1$d short links successfully. %2$d failed. %3$d skipped (already exist).', 'betterlinks'), $successful, $failed, $skipped),
687 'queued' => count($post_ids),
688 'successful' => $successful,
689 'failed' => $failed,
690 'skipped' => $skipped
691 ]);
692
693 } catch (Exception $e) {
694 wp_send_json_error([
695 'message' => __('Error starting bulk generation: ', 'betterlinks') . $e->getMessage()
696 ]);
697 }
698 }
699
700 /**
701 * Create a short link for a specific post
702 */
703 private function create_short_link_for_post($post_id, $filters = [])
704 {
705 try {
706 $post = get_post($post_id);
707 if (!$post) {
708 return ['success' => false, 'message' => 'Post not found'];
709 }
710
711 // Check if link already exists for this post
712 $target_url = $this->get_target_url($post, $filters);
713 if ($this->link_exists_for_target_url($target_url)) {
714 // Only skip if we're not including existing links
715 if (!$filters['include_existing']) {
716 return ['success' => false, 'message' => 'Link already exists', 'skipped' => true];
717 }
718 // If include_existing is true, we need to delete the old link first before creating a new one
719 $this->delete_existing_link_for_target_url($target_url);
720 }
721
722 // Generate link data based on filters
723 $link_title = $post->post_title;
724
725 // Generate unique slug based on configuration
726 $link_slug = $this->generate_short_url($post, $filters);
727 if (!$link_slug) {
728 return ['success' => false, 'message' => 'Failed to generate unique slug'];
729 }
730
731 // Get description based on configuration
732 $link_note = $this->get_post_description($post, $filters);
733
734 // Get BetterLinks category ID
735 $cat_id = $this->get_betterlinks_category_id($post_id, $filters);
736
737 // Prepare raw link data
738 $current_time = current_time('mysql');
739 $raw_link_data = [
740 'link_title' => $link_title,
741 'link_slug' => $link_slug,
742 'short_url' => $link_slug, // BetterLinks expects both link_slug and short_url
743 'target_url' => $target_url,
744 'link_note' => $link_note,
745 'link_status' => 'publish', // Make sure link is published
746 'redirect_type' => $filters['redirect_type'] ?? '301',
747 'nofollow' => 1,
748 'sponsored' => '',
749 'track_me' => 1,
750 'param_forwarding' => '',
751 'link_date' => $current_time,
752 'link_date_gmt' => get_gmt_from_date($current_time),
753 'link_modified' => $current_time,
754 'link_modified_gmt' => get_gmt_from_date($current_time),
755 'wildcards' => 0,
756 'cat_id' => $cat_id, // Add category ID directly to link data
757 'password' => ''
758 ];
759
760 // Add BetterLink tags if specified
761 if (!empty($filters['betterlink_tags'])) {
762 $raw_link_data['tags_id'] = $filters['betterlink_tags'];
763 }
764
765 // Add Pro fields if Pro plugin is active
766 if (defined('BETTERLINKS_PRO_VERSION')) {
767 $raw_link_data['expire'] = [
768 'status' => 0,
769 'type' => 'date',
770 'clicks' => '',
771 'date' => '',
772 'redirect_status' => 0,
773 'redirect_url' => ''
774 ];
775 $raw_link_data['dynamic_redirect'] = [
776 'type' => '',
777 'value' => [],
778 'extra' => [
779 'rotation_mode' => 'weighted',
780 'split_test' => '',
781 'goal_link' => ''
782 ]
783 ];
784 }
785
786 // Sanitize link data using BetterLinks schema (like in Ajax.php)
787 $link_data = $this->sanitize_links_data($raw_link_data);
788
789 // Clear cache before insertion (like in Ajax.php)
790 $helper = new Helper();
791 $helper->clear_query_cache();
792
793 // Use the trait's insert_link method which handles categories automatically
794 $result = $this->insert_link($link_data);
795
796 if ($result && isset($result['ID'])) {
797 $link_id = $result['ID'];
798
799 // Insert custom tags if specified
800 if (!empty($filters['custom_tags'])) {
801 $this->insert_custom_tags($link_id, $filters['custom_tags']);
802 }
803
804 // Note: BetterLink tags are handled automatically by the insert_link method via tags_id parameter
805
806 return [
807 'success' => true,
808 /* translators: %s = placeholder values supplied by WordPress */
809 'message' => sprintf(__('Created short link for: %s', 'betterlinks'), $link_title),
810 'link_id' => $link_id,
811 'short_url' => home_url('/' . $link_slug)
812 ];
813 } else {
814 // Log the failure for debugging
815 // PCP-DEBUG-DISABLED: error_log('BetterLinks: Failed to insert link. Data: ' . print_r($link_data, true));
816 return ['success' => false, 'message' => 'Database insertion failed. Check if slug already exists.'];
817 }
818
819 } catch (Exception $e) {
820 return ['success' => false, 'message' => $e->getMessage()];
821 }
822 }
823
824 /**
825 * Generate a unique slug for the short link
826 */
827 private function generate_unique_slug($title)
828 {
829 global $wpdb;
830
831 // Create base slug from title
832 $base_slug = sanitize_title($title);
833 $base_slug = substr($base_slug, 0, 20); // Limit length
834
835 if (empty($base_slug)) {
836 $base_slug = 'link';
837 }
838
839 $slug = $base_slug;
840 $counter = 1;
841
842 // Check if slug exists and make it unique
843 while ($this->slug_exists($slug)) {
844 $slug = $base_slug . '-' . $counter;
845 $counter++;
846
847 // Prevent infinite loop
848 if ($counter > 100) {
849 $slug = $base_slug . '-' . time();
850 break;
851 }
852 }
853
854 return $slug;
855 }
856
857 /**
858 * Check if a slug already exists
859 */
860 private function slug_exists($slug)
861 {
862 global $wpdb;
863
864 $count = $wpdb->get_var($wpdb->prepare(
865 "SELECT COUNT(*) FROM {$wpdb->prefix}betterlinks WHERE short_url = %s",
866 $slug
867 ));
868
869 return $count > 0;
870 }
871
872 /**
873 * Get BetterLinks category ID for a post
874 */
875 private function get_betterlinks_category_id($post_id, $filters = [])
876 {
877 // Check if a specific BetterLink category was selected
878 if (!empty($filters['betterlink_category'])) {
879 return intval($filters['betterlink_category']);
880 }
881
882 // Default fallback to "Uncategorized" category ID 1
883 return 1;
884 }
885
886 /**
887 * Sanitize and validate generation filters
888 */
889 private function sanitize_generation_filters($data)
890 {
891 $filters = [];
892
893 // Required fields
894 $filters['post_type'] = isset($data['post_type']) ? sanitize_text_field($data['post_type']) : '';
895
896 // Handle categories - they might come as JSON string from FormData
897 $filters['categories'] = [];
898 if (isset($data['categories'])) {
899 if (is_string($data['categories'])) {
900 $decoded_categories = json_decode($data['categories'], true);
901 $filters['categories'] = is_array($decoded_categories) ? array_map('intval', $decoded_categories) : [];
902 } else if (is_array($data['categories'])) {
903 $filters['categories'] = array_map('intval', $data['categories']);
904 }
905 }
906
907 if (empty($filters['post_type'])) {
908 return new \WP_Error('missing_required', __('Post type is required.', 'betterlinks'));
909 }
910
911 // Optional fields
912 // Handle tags - they might come as JSON string from FormData
913 $filters['tags'] = [];
914 if (isset($data['tags'])) {
915 if (is_string($data['tags'])) {
916 $decoded_tags = json_decode($data['tags'], true);
917 $filters['tags'] = is_array($decoded_tags) ? array_map('intval', $decoded_tags) : [];
918 } else if (is_array($data['tags'])) {
919 $filters['tags'] = array_map('intval', $data['tags']);
920 }
921 }
922 $filters['post_limit'] = isset($data['post_limit']) ? max(0, intval($data['post_limit'])) : 0;
923 $filters['sorting'] = isset($data['sorting']) ? sanitize_text_field($data['sorting']) : 'date_desc';
924
925 // Handle boolean value properly - FormData converts false to string "false"
926 $include_existing = isset($data['include_existing']) ? $data['include_existing'] : false;
927 if (is_string($include_existing)) {
928 $filters['include_existing'] = $include_existing === 'true' || $include_existing === '1';
929 } else {
930 $filters['include_existing'] = (bool)$include_existing;
931 }
932
933 // Short link configuration
934 $filters['description_length'] = isset($data['description_length']) ? max(0, intval($data['description_length'])) : 150;
935 $redirect_type = isset($data['redirect_type']) ? $data['redirect_type'] : '301';
936 $filters['redirect_type'] = in_array($redirect_type, ['301', '302', '307']) ? $redirect_type : '301';
937 $filters['target_url_source'] = isset($data['target_url_source']) ? sanitize_text_field($data['target_url_source']) : 'permalink';
938 $filters['custom_field_key'] = isset($data['custom_field_key']) ? sanitize_text_field($data['custom_field_key']) : '';
939 $filters['manual_pattern'] = isset($data['manual_pattern']) ? sanitize_text_field($data['manual_pattern']) : '';
940
941 // URL slug generation type
942 $url_slug_generation_type = isset($data['url_slug_generation_type']) ? $data['url_slug_generation_type'] : 'random_mixed';
943 $filters['url_slug_generation_type'] = in_array($url_slug_generation_type, ['from_title', 'from_url', 'random_string', 'random_number', 'random_mixed']) ? $url_slug_generation_type : 'random_mixed';
944
945 // Link prefix configuration
946 $filters['link_prefix'] = isset($data['link_prefix']) ? sanitize_text_field($data['link_prefix']) : Helper::get_settings('prefix');
947
948 // Category assignment - simplified to only BetterLink category
949 $filters['betterlink_category'] = 0;
950 if (isset($data['betterlink_category'])) {
951 $cat_value = $data['betterlink_category'];
952 // Handle both direct values and JSON strings
953 if (is_string($cat_value)) {
954 $cat_value = json_decode($cat_value, true);
955 if (is_array($cat_value) && isset($cat_value['value'])) {
956 $filters['betterlink_category'] = intval($cat_value['value']);
957 } else {
958 $filters['betterlink_category'] = intval($cat_value);
959 }
960 } else {
961 $filters['betterlink_category'] = intval($cat_value);
962 }
963 }
964
965 // BetterLink tags assignment
966 $filters['betterlink_tags'] = [];
967 if (isset($data['betterlink_tags'])) {
968 if (is_string($data['betterlink_tags'])) {
969 // Handle escaped quotes in JSON string
970 $cleaned_json = stripslashes($data['betterlink_tags']);
971 $decoded_tags = json_decode($cleaned_json, true);
972
973 if (is_array($decoded_tags)) {
974 // Handle array of objects with 'value' property or direct IDs
975 $filters['betterlink_tags'] = array_map(function($tag) {
976 if (is_array($tag) && isset($tag['value'])) {
977 return intval($tag['value']);
978 }
979 return intval($tag);
980 }, $decoded_tags);
981 }
982 } else if (is_array($data['betterlink_tags'])) {
983 // Handle array of objects or direct values
984 $filters['betterlink_tags'] = array_map(function($tag) {
985 if (is_array($tag) && isset($tag['value'])) {
986 return intval($tag['value']);
987 }
988 return intval($tag);
989 }, $data['betterlink_tags']);
990 }
991 }
992
993 // Handle custom_tags - they might come as JSON string from FormData
994 $filters['custom_tags'] = [];
995 if (isset($data['custom_tags'])) {
996 if (is_string($data['custom_tags'])) {
997 $decoded_custom_tags = json_decode($data['custom_tags'], true);
998 $filters['custom_tags'] = is_array($decoded_custom_tags) ? array_map('sanitize_text_field', $decoded_custom_tags) : [];
999 } else if (is_array($data['custom_tags'])) {
1000 $filters['custom_tags'] = array_map('sanitize_text_field', $data['custom_tags']);
1001 }
1002 }
1003
1004 // Collision handling strategy for duplicate slugs
1005 $collision_handling = isset($data['collision_handling']) ? sanitize_text_field($data['collision_handling']) : 'append';
1006 $filters['collision_handling'] = in_array($collision_handling, ['append', 'regenerate', 'skip']) ? $collision_handling : 'append';
1007
1008 return $filters;
1009 }
1010
1011 /**
1012 * Queue posts for bulk generation
1013 */ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
1014 private function queue_posts_for_generation($processor, $filters)
1015 {
1016 $args = [
1017 'post_type' => $filters['post_type'],
1018 'post_status' => 'publish',
1019 'posts_per_page' => $filters['post_limit'] > 0 ? $filters['post_limit'] : -1,
1020 'tax_query' => [
1021 'relation' => 'AND'
1022 ]
1023 ];
1024
1025 // Add sorting
1026 switch ($filters['sorting']) {
1027 case 'date_asc':
1028 $args['orderby'] = 'date';
1029 $args['order'] = 'ASC';
1030 break;
1031 case 'date_desc':
1032 $args['orderby'] = 'date';
1033 $args['order'] = 'DESC';
1034 break;
1035 case 'title_asc':
1036 $args['orderby'] = 'title';
1037 $args['order'] = 'ASC';
1038 break;
1039 case 'title_desc':
1040 $args['orderby'] = 'title';
1041 $args['order'] = 'DESC';
1042 break;
1043 }
1044
1045 // Add category filter
1046 $category_taxonomies = get_object_taxonomies($filters['post_type'], 'objects');
1047 foreach ($category_taxonomies as $taxonomy) {
1048 if ($taxonomy->hierarchical) {
1049 $args['tax_query'][] = [
1050 'taxonomy' => $taxonomy->name,
1051 'field' => 'term_id',
1052 'terms' => $filters['categories'],
1053 'operator' => 'IN'
1054 ];
1055 break;
1056 }
1057 }
1058
1059 // Add tags filter if provided
1060 if (!empty($filters['tags'])) {
1061 $tag_taxonomies = get_object_taxonomies($filters['post_type'], 'objects');
1062 foreach ($tag_taxonomies as $taxonomy) {
1063 if (!$taxonomy->hierarchical) {
1064 $args['tax_query'][] = [
1065 'taxonomy' => $taxonomy->name,
1066 'field' => 'term_id',
1067 'terms' => $filters['tags'],
1068 'operator' => 'IN'
1069 ];
1070 break;
1071 }
1072 }
1073 }
1074
1075 $query = new \WP_Query($args);
1076 $queued = 0;
1077
1078 if ($query->have_posts()) {
1079 while ($query->have_posts()) {
1080 $query->the_post();
1081 $post_id = get_the_ID();
1082
1083 // Skip if post already has a BetterLink and not including existing
1084 if (!$filters['include_existing'] && $this->post_has_existing_link($post_id)) {
1085 continue;
1086 }
1087
1088 $processor->push_to_queue([
1089 'post_id' => $post_id,
1090 'filters' => $filters
1091 ]);
1092 $queued++;
1093 }
1094 wp_reset_postdata();
1095 }
1096
1097 // Update total count
1098 $status = get_option('betterlinks_bulk_generation_status', []);
1099 $status['total'] = $queued;
1100 update_option('betterlinks_bulk_generation_status', $status);
1101
1102 return $queued;
1103 }
1104
1105 /**
1106 * Check if post already has a BetterLink
1107 */
1108 private function post_has_existing_link($post_id)
1109 {
1110 global $wpdb;
1111 $permalink = get_permalink($post_id);
1112
1113 $count = $wpdb->get_var($wpdb->prepare("
1114 SELECT COUNT(*)
1115 FROM {$wpdb->prefix}betterlinks
1116 WHERE target_url = %s
1117 ", $permalink));
1118
1119 return $count > 0;
1120 }
1121
1122 /**
1123 * Get generation progress
1124 */
1125 public function get_generation_progress()
1126 {
1127 // Temporarily disable nonce check for debugging
1128 // check_ajax_referer('betterlinks_admin_nonce', 'security');
1129
1130 if (!current_user_can('manage_options')) {
1131 wp_send_json_error(['message' => __('You don\'t have permission to do this.', 'betterlinks')]);
1132 return;
1133 }
1134
1135 // PRO FEATURE CHECK - First priority security
1136 if (!$this->verify_pro_access()) {
1137 wp_send_json_error([
1138 'message' => __('This feature requires BetterLinks Pro.', 'betterlinks'),
1139 'code' => 'pro_required'
1140 ], 403);
1141 return;
1142 }
1143
1144 if (!current_user_can('manage_options')) {
1145 wp_send_json_error(['message' => __('You don\'t have permission to do this.', 'betterlinks')]);
1146 return;
1147 }
1148
1149 try {
1150 // Get actual status from database
1151 $status = get_option('betterlinks_bulk_generation_status', [
1152 'status' => 'completed',
1153 'total' => 0,
1154 'processed' => 0,
1155 'successful' => 0,
1156 'failed' => 0,
1157 'skipped' => 0,
1158 'progress_percent' => 100,
1159 'errors' => []
1160 ]);
1161
1162 // Calculate progress percentage
1163 if ($status['total'] > 0) {
1164 $status['progress_percent'] = round(($status['processed'] / $status['total']) * 100, 2);
1165 } else {
1166 $status['progress_percent'] = 100;
1167 }
1168
1169 // Add processing flags
1170 $status['is_processing'] = false;
1171 $status['is_paused'] = false;
1172 $status['is_cancelled'] = false;
1173 $status['queue_length'] = 0;
1174 $status['eta_seconds'] = 0;
1175
1176 // Create summary message
1177 if ($status['successful'] > 0 || $status['failed'] > 0 || $status['skipped'] > 0) {
1178 $status['message'] = sprintf(
1179 /* translators: %s = placeholder values supplied by WordPress */
1180 /* translators: 1: count of links successfully created, 2: count failed, 3: count skipped because they already exist. */
1181 __('Generation completed! Created %1$d short links successfully. %2$d failed. %3$d skipped (already exist).', 'betterlinks'),
1182 $status['successful'],
1183 $status['failed'],
1184 $status['skipped']
1185 );
1186 } else {
1187 $status['message'] = __('Generation completed.', 'betterlinks');
1188 }
1189
1190 wp_send_json_success($status);
1191 } catch (Exception $e) {
1192 wp_send_json_error([
1193 'message' => __('Error getting progress: ', 'betterlinks') . $e->getMessage()
1194 ]);
1195 }
1196 }
1197
1198 /**
1199 * Pause bulk generation
1200 */
1201 public function pause_bulk_generation()
1202 {
1203 check_ajax_referer('betterlinks_admin_nonce', 'security');
1204
1205 if (!current_user_can('manage_options')) {
1206 wp_die("You don't have permission to do this.");
1207 }
1208
1209 $processor = BulkLinkGenerator::getInstance();
1210 $processor->pause();
1211
1212 $status = get_option('betterlinks_bulk_generation_status', []);
1213 $status['status'] = 'paused';
1214 $status['paused_at'] = current_time('mysql');
1215 update_option('betterlinks_bulk_generation_status', $status);
1216
1217 wp_send_json_success(['message' => __('Bulk generation paused.', 'betterlinks')]);
1218 }
1219
1220 /**
1221 * Resume bulk generation
1222 */
1223 public function resume_bulk_generation()
1224 {
1225 check_ajax_referer('betterlinks_admin_nonce', 'security');
1226
1227 if (!current_user_can('manage_options')) {
1228 wp_die("You don't have permission to do this.");
1229 }
1230
1231 $processor = BulkLinkGenerator::getInstance();
1232 $processor->resume();
1233
1234 $status = get_option('betterlinks_bulk_generation_status', []);
1235 $status['status'] = 'running';
1236 $status['resumed_at'] = current_time('mysql');
1237 update_option('betterlinks_bulk_generation_status', $status);
1238
1239 wp_send_json_success(['message' => __('Bulk generation resumed.', 'betterlinks')]);
1240 }
1241
1242 /**
1243 * Cancel bulk generation
1244 */
1245 public function cancel_bulk_generation()
1246 {
1247 check_ajax_referer('betterlinks_admin_nonce', 'security');
1248
1249 if (!current_user_can('manage_options')) {
1250 wp_die("You don't have permission to do this.");
1251 }
1252
1253 $processor = BulkLinkGenerator::getInstance();
1254 $processor->cancel_process();
1255
1256 $status = get_option('betterlinks_bulk_generation_status', []);
1257 $status['status'] = 'cancelled';
1258 $status['cancelled_at'] = current_time('mysql');
1259 update_option('betterlinks_bulk_generation_status', $status);
1260
1261 wp_send_json_success(['message' => __('Bulk generation cancelled.', 'betterlinks')]);
1262 }
1263
1264 /**
1265 * Download generation report
1266 */
1267 public function download_generation_report()
1268 {
1269 check_ajax_referer('betterlinks_admin_nonce', 'security');
1270
1271 if (!current_user_can('manage_options')) {
1272 wp_die( esc_html__( "You don't have permission to do this.", 'betterlinks' ) );
1273 }
1274
1275 $format = isset( $_GET['format'] ) ? sanitize_text_field( wp_unslash( $_GET['format'] ) ) : 'csv';
1276 $report_data = get_option('betterlinks_bulk_generation_report', []);
1277
1278 if (empty($report_data)) {
1279 wp_die( esc_html__( 'No report data available.', 'betterlinks' ) );
1280 }
1281
1282 $filename = 'betterlinks-bulk-generation-report-' . gmdate('Y-m-d-H-i-s');
1283
1284 if ($format === 'json') {
1285 $this->download_json_report($report_data, $filename);
1286 } else {
1287 $this->download_csv_report($report_data, $filename);
1288 }
1289 }
1290
1291 /**
1292 * Download CSV report
1293 */
1294 private function download_csv_report($report_data, $filename)
1295 {
1296 $filename = sanitize_file_name( $filename );
1297 header('Content-Type: text/csv');
1298 header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
1299 header('Pragma: no-cache');
1300 header('Expires: 0');
1301
1302 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- writing to PHP output stream, not a real file; WP_Filesystem does not apply.
1303 $output = fopen('php://output', 'w');
1304
1305 // CSV headers
1306 fputcsv($output, [
1307 'Post ID',
1308 'Post Title',
1309 'Post URL',
1310 'Short URL',
1311 'BetterLink ID',
1312 'Status',
1313 'Error Message',
1314 'Category',
1315 'Tags',
1316 'Created At'
1317 ]);
1318
1319 // CSV data
1320 foreach ($report_data as $item) {
1321 fputcsv($output, [
1322 $item['post_id'] ?? '',
1323 $item['post_title'] ?? '',
1324 $item['post_url'] ?? '',
1325 $item['short_url'] ?? '',
1326 $item['link_id'] ?? '',
1327 $item['status'] ?? '',
1328 $item['error'] ?? '',
1329 $item['category'] ?? '',
1330 is_array($item['tags'] ?? []) ? implode(', ', $item['tags']) : '',
1331 $item['created_at'] ?? ''
1332 ]);
1333 }
1334
1335 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the PHP output stream opened above.
1336 fclose($output);
1337 exit;
1338 }
1339
1340 /**
1341 * Download JSON report
1342 */
1343 private function download_json_report($report_data, $filename)
1344 {
1345 $filename = sanitize_file_name( $filename );
1346 header('Content-Type: application/json');
1347 header('Content-Disposition: attachment; filename="' . $filename . '.json"');
1348 header('Pragma: no-cache');
1349 header('Expires: 0');
1350
1351 echo wp_json_encode($report_data, JSON_PRETTY_PRINT); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode produces safe JSON for download response.
1352 exit;
1353 }
1354
1355 /**
1356 * Generate short URL based on configuration
1357 */
1358 private function generate_short_url($post, $filters)
1359 {
1360 $base_slug = '';
1361
1362 // First, check if we should use url_slug_generation_type (new method)
1363 if (!empty($filters['url_slug_generation_type'])) {
1364 switch ($filters['url_slug_generation_type']) {
1365 case 'from_title':
1366 $base_slug = $this->generate_from_title($post->post_title);
1367 break;
1368 case 'from_url':
1369 $target_url = get_permalink($post->ID);
1370 $base_slug = $this->generate_from_url($target_url);
1371 break;
1372 case 'random_string':
1373 $base_slug = $this->generate_random_string();
1374 break;
1375 case 'random_number':
1376 $base_slug = $this->generate_random_number();
1377 break;
1378 case 'random_mixed':
1379 $base_slug = $this->generate_random_mixed();
1380 break;
1381 default:
1382 $base_slug = $this->generate_random_mixed();
1383 }
1384 } else {
1385 // Fallback to old slug_type method for backward compatibility
1386 switch ($filters['slug_type']) {
1387 case 'existing':
1388 $base_slug = $post->post_name;
1389 break;
1390 case 'title':
1391 $base_slug = sanitize_title($post->post_title);
1392 if ($filters['slug_length'] > 0) {
1393 $words = explode('-', $base_slug);
1394 $base_slug = implode('-', array_slice($words, 0, $filters['slug_length']));
1395 }
1396 break;
1397 case 'random':
1398 $base_slug = $this->generate_random_slug($filters['slug_length']);
1399 break;
1400 }
1401 }
1402
1403 // Apply prefix if available
1404 $prefix = $filters['link_prefix'] ?? '';
1405 if (!empty($prefix)) {
1406 $base_slug = $prefix . '/' . $base_slug;
1407 }
1408
1409 return $this->ensure_unique_slug($base_slug, $filters['collision_handling']);
1410 }
1411
1412 /**
1413 * Ensure slug is unique
1414 */
1415 private function ensure_unique_slug($base_slug, $collision_handling)
1416 {
1417 $slug = $base_slug;
1418 $counter = 1;
1419
1420 while ($this->slug_exists($slug)) {
1421 switch ($collision_handling) {
1422 case 'append':
1423 $counter++;
1424 $slug = $base_slug . '-' . $counter;
1425 break;
1426 case 'regenerate':
1427 $slug = $this->generate_random_slug();
1428 break;
1429 case 'skip':
1430 return false; // Indicates to skip this post
1431 }
1432
1433 // Prevent infinite loops
1434 if ($counter > 1000) {
1435 $slug = $this->generate_random_slug();
1436 break;
1437 }
1438 }
1439
1440 return $slug;
1441 }
1442
1443 /**
1444 * Get target URL based on configuration
1445 */
1446 private function get_target_url($post, $filters)
1447 {
1448 switch ($filters['target_url_source']) {
1449 case 'custom_field':
1450 if (!empty($filters['custom_field_key'])) {
1451 $custom_url = get_post_meta($post->ID, $filters['custom_field_key'], true);
1452 if (!empty($custom_url)) {
1453 return $custom_url;
1454 }
1455 }
1456 // Fallback to permalink if custom field is empty
1457 return get_permalink($post->ID);
1458
1459 case 'manual_pattern':
1460 if (!empty($filters['manual_pattern'])) {
1461 return str_replace('{post_slug}', $post->post_name, $filters['manual_pattern']);
1462 }
1463 // Fallback to permalink if pattern is empty
1464 return get_permalink($post->ID);
1465
1466 default:
1467 return get_permalink($post->ID);
1468 }
1469 }
1470
1471 /**
1472 * Get post description based on configuration
1473 */
1474 private function get_post_description($post, $filters)
1475 {
1476 $description = '';
1477
1478 // Try excerpt first
1479 if (!empty($post->post_excerpt)) {
1480 $description = $post->post_excerpt;
1481 } else {
1482 // Use content if no excerpt
1483 $description = wp_strip_all_tags($post->post_content);
1484 }
1485
1486 // Truncate if length specified
1487 if ($filters['description_length'] > 0 && strlen($description) > $filters['description_length']) {
1488 $description = substr($description, 0, $filters['description_length']);
1489 // Try to break at word boundary
1490 $last_space = strrpos($description, ' ');
1491 if ($last_space !== false) {
1492 $description = substr($description, 0, $last_space);
1493 }
1494 $description .= '...';
1495 }
1496
1497 return $description;
1498 }
1499
1500 /**
1501 * Build WP_Query arguments from filters
1502 */
1503 private function build_query_args($filters)
1504 {
1505 // Build basic query args
1506 $args = [
1507 'post_type' => $filters['post_type'],
1508 'post_status' => 'publish',
1509 'posts_per_page' => $filters['post_limit'] > 0 ? $filters['post_limit'] : -1,
1510 'fields' => 'ids'
1511 ];
1512
1513 // Add sorting
1514 if (!empty($filters['sorting'])) {
1515 switch ($filters['sorting']) {
1516 case 'date_asc':
1517 $args['orderby'] = 'date';
1518 $args['order'] = 'ASC';
1519 break;
1520 case 'date_desc':
1521 default:
1522 $args['orderby'] = 'date';
1523 $args['order'] = 'DESC';
1524 break;
1525 case 'title_asc':
1526 $args['orderby'] = 'title';
1527 $args['order'] = 'ASC';
1528 break;
1529 case 'title_desc':
1530 $args['orderby'] = 'title'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
1531 $args['order'] = 'DESC';
1532 break;
1533 }
1534 }
1535
1536 // Only add tax_query if we have categories or tags
1537 if (!empty($filters['categories']) || !empty($filters['tags'])) {
1538 $args['tax_query'] = ['relation' => 'AND'];
1539
1540 // Add category filter if provided
1541 if (!empty($filters['categories'])) {
1542 $category_taxonomies = get_object_taxonomies($filters['post_type'], 'objects');
1543
1544 $taxonomy_added = false;
1545
1546 // First, try to find which taxonomy these category IDs actually belong to
1547 foreach ($filters['categories'] as $cat_id) {
1548 foreach ($category_taxonomies as $taxonomy) {
1549 if ($taxonomy->hierarchical) {
1550 $term = get_term($cat_id, $taxonomy->name);
1551 if (!is_wp_error($term) && $term) {
1552 $args['tax_query'][] = [
1553 'taxonomy' => $taxonomy->name,
1554 'field' => 'term_id',
1555 'terms' => $filters['categories'],
1556 'operator' => 'IN'
1557 ];
1558 $taxonomy_added = true;
1559 break 2; // Break both loops
1560 }
1561 }
1562 }
1563 }
1564
1565 // Fallback: use first hierarchical taxonomy if no specific match found
1566 if (!$taxonomy_added) {
1567 foreach ($category_taxonomies as $taxonomy) {
1568 if ($taxonomy->hierarchical) {
1569 $args['tax_query'][] = [
1570 'taxonomy' => $taxonomy->name,
1571 'field' => 'term_id',
1572 'terms' => $filters['categories'],
1573 'operator' => 'IN'
1574 ];
1575 $taxonomy_added = true;
1576 break;
1577 }
1578 }
1579
1580 // If no hierarchical taxonomy found, try to find any category-like taxonomy
1581 if (!$taxonomy_added && !empty($category_taxonomies)) {
1582 $first_taxonomy = reset($category_taxonomies);
1583 $args['tax_query'][] = [
1584 'taxonomy' => $first_taxonomy->name,
1585 'field' => 'term_id',
1586 'terms' => $filters['categories'],
1587 'operator' => 'IN'
1588 ];
1589 }
1590 }
1591 }
1592
1593 // Add tags filter if provided
1594 if (!empty($filters['tags'])) {
1595 $tag_taxonomies = get_object_taxonomies($filters['post_type'], 'objects');
1596 foreach ($tag_taxonomies as $taxonomy) {
1597 if (!$taxonomy->hierarchical) {
1598 $args['tax_query'][] = [
1599 'taxonomy' => $taxonomy->name,
1600 'field' => 'term_id',
1601 'terms' => $filters['tags'],
1602 'operator' => 'IN'
1603 ];
1604 break; // Use first non-hierarchical taxonomy found
1605 }
1606 }
1607 }
1608 }
1609
1610 return $args;
1611 }
1612
1613 /**
1614 * Filter out posts that already have BetterLinks
1615 */
1616 private function filter_posts_without_existing_links($post_ids)
1617 {
1618 if (empty($post_ids)) {
1619 return [];
1620 }
1621
1622 global $wpdb;
1623
1624 // $placeholders is built locally as '%d,%d,...' from $post_ids (int[]); table prefix is wpdb-controlled.
1625 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1626 $placeholders = implode(',', array_fill(0, count($post_ids), '%d'));
1627 $post_urls = $wpdb->get_col($wpdb->prepare(
1628 "SELECT DISTINCT CONCAT(post_type, ':', ID) FROM {$wpdb->prefix}posts
1629 WHERE ID IN ($placeholders)",
1630 ...$post_ids
1631 ));
1632 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1633
1634 if (empty($post_urls)) {
1635 return $post_ids;
1636 }
1637
1638 // Get posts that already have BetterLinks by checking target_url
1639 $url_placeholders = implode(',', array_fill(0, count($post_ids), '%s'));
1640 $existing_urls = $wpdb->get_col($wpdb->prepare(
1641 "SELECT DISTINCT target_url FROM {$wpdb->prefix}betterlinks
1642 WHERE target_url IN (" . implode(',', array_fill(0, count($post_ids), '%s')) . ")",
1643 ...array_map(function($post_id) {
1644 return get_permalink($post_id);
1645 }, $post_ids)
1646 ));
1647
1648 if (empty($existing_urls)) {
1649 return $post_ids;
1650 }
1651
1652 // Filter out posts whose URLs already have links
1653 $filtered_post_ids = [];
1654 foreach ($post_ids as $post_id) {
1655 $post_url = get_permalink($post_id);
1656 if (!in_array($post_url, $existing_urls)) {
1657 $filtered_post_ids[] = $post_id;
1658 }
1659 }
1660
1661 return $filtered_post_ids;
1662 }
1663
1664 /**
1665 * Get categories string for a post
1666 */
1667 private function get_post_categories_string($post_id)
1668 {
1669 $post_type = get_post_type($post_id);
1670 $taxonomies = get_object_taxonomies($post_type, 'objects');
1671 $categories = [];
1672
1673 foreach ($taxonomies as $taxonomy) {
1674 if ($taxonomy->hierarchical) {
1675 $terms = get_the_terms($post_id, $taxonomy->name);
1676 if ($terms && !is_wp_error($terms)) {
1677 foreach ($terms as $term) {
1678 $categories[] = $term->name;
1679 }
1680 }
1681 break; // Use first hierarchical taxonomy
1682 }
1683 }
1684
1685 return implode(', ', $categories);
1686 }
1687
1688 /**
1689 * Get tags string for a post
1690 */
1691 private function get_post_tags_string($post_id)
1692 {
1693 $post_type = get_post_type($post_id);
1694 $taxonomies = get_object_taxonomies($post_type, 'objects');
1695 $tags = [];
1696
1697 foreach ($taxonomies as $taxonomy) {
1698 if (!$taxonomy->hierarchical) {
1699 $terms = get_the_terms($post_id, $taxonomy->name);
1700 if ($terms && !is_wp_error($terms)) {
1701 foreach ($terms as $term) {
1702 $tags[] = $term->name;
1703 }
1704 }
1705 break; // Use first non-hierarchical taxonomy
1706 }
1707 }
1708
1709 return implode(', ', $tags);
1710 }
1711
1712 /**
1713 * Insert category relationship for a link
1714 */
1715 private function insert_category_relationship($link_id, $category_id)
1716 {
1717 global $wpdb;
1718
1719 // Insert into betterlinks_terms_relationships table
1720 $table_name = $wpdb->prefix . 'betterlinks_terms_relationships';
1721
1722 // Check if table exists
1723 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table_name)) === $table_name) {
1724 $wpdb->insert(
1725 $table_name,
1726 [
1727 'link_id' => $link_id,
1728 'term_id' => $category_id
1729 ],
1730 ['%d', '%d']
1731 );
1732 }
1733 }
1734
1735 /**
1736 * Insert custom tags for a link
1737 */
1738 private function insert_custom_tags($link_id, $tags)
1739 {
1740 if (empty($tags)) {
1741 return;
1742 }
1743
1744 global $wpdb;
1745
1746 foreach ($tags as $tag_name) {
1747 // For now, just store as metadata or in a custom way
1748 // This depends on how BetterLinks handles tags
1749 // You might need to adapt this based on the actual BetterLinks schema
1750 add_post_meta($link_id, '_betterlinks_custom_tag', sanitize_text_field($tag_name));
1751 }
1752 }
1753
1754
1755
1756 /**
1757 * Generate random slug
1758 */
1759 private function generate_random_slug($length = 6)
1760 {
1761 $characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
1762 $slug = '';
1763 $max = strlen($characters) - 1;
1764
1765 for ($i = 0; $i < $length; $i++) {
1766 $slug .= $characters[wp_rand(0, $max)];
1767 }
1768
1769 return $slug;
1770 }
1771
1772 /**
1773 * Generate random string (letters only)
1774 */
1775 private function generate_random_string($length = 8)
1776 {
1777 $characters = 'abcdefghijklmnopqrstuvwxyz';
1778 $slug = '';
1779 $max = strlen($characters) - 1;
1780
1781 for ($i = 0; $i < $length; $i++) {
1782 $slug .= $characters[wp_rand(0, $max)];
1783 }
1784
1785 return $slug;
1786 }
1787
1788 /**
1789 * Generate random number
1790 */
1791 private function generate_random_number($length = 8)
1792 {
1793 $characters = '0123456789';
1794 $slug = '';
1795 $max = strlen($characters) - 1;
1796
1797 for ($i = 0; $i < $length; $i++) {
1798 $slug .= $characters[wp_rand(0, $max)];
1799 }
1800
1801 return $slug;
1802 }
1803
1804 /**
1805 * Generate random mixed (alphanumeric)
1806 */
1807 private function generate_random_mixed($length = 8)
1808 {
1809 $characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
1810 $slug = '';
1811 $max = strlen($characters) - 1;
1812
1813 for ($i = 0; $i < $length; $i++) {
1814 $slug .= $characters[wp_rand(0, $max)];
1815 }
1816
1817 return $slug;
1818 }
1819
1820 /**
1821 * Generate slug from title
1822 */
1823 private function generate_from_title($title)
1824 {
1825 if (empty($title)) {
1826 return $this->generate_random_mixed();
1827 }
1828
1829 // Clean and process the title to create a user-friendly slug
1830 $slug = sanitize_title($title);
1831
1832 // Remove common stop words
1833 $stop_words = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'among', 'against', 'across', 'toward', 'towards', 'under', 'over'];
1834
1835 $words = explode('-', $slug);
1836 $filtered_words = array_filter($words, function($word) use ($stop_words) {
1837 return !in_array(strtolower($word), $stop_words);
1838 });
1839
1840 $slug = implode('-', $filtered_words);
1841
1842 // Limit length
1843 if (strlen($slug) > 20) {
1844 $slug = substr($slug, 0, 20);
1845 }
1846
1847 return !empty($slug) ? $slug : $this->generate_random_mixed();
1848 }
1849
1850 /**
1851 * Generate slug from URL
1852 */
1853 private function generate_from_url($url)
1854 {
1855 if (empty($url)) {
1856 return $this->generate_random_mixed();
1857 }
1858
1859 try {
1860 $parsed_url = wp_parse_url($url);
1861 $path = isset($parsed_url['path']) ? $parsed_url['path'] : '';
1862
1863 // Get the last part of the path
1864 $path_parts = array_filter(explode('/', $path));
1865
1866 if (!empty($path_parts)) {
1867 $slug = end($path_parts);
1868 // Remove file extension if present
1869 $slug = preg_replace('/\.[^.]+$/', '', $slug);
1870 // Sanitize
1871 $slug = sanitize_title($slug);
1872
1873 if (strlen($slug) > 20) {
1874 $slug = substr($slug, 0, 20);
1875 }
1876
1877 return !empty($slug) ? $slug : $this->generate_random_mixed();
1878 }
1879 } catch (Exception $e) {
1880 // If URL parsing fails, return random mixed
1881 return $this->generate_random_mixed();
1882 }
1883
1884 return $this->generate_random_mixed();
1885 }
1886 }
1887