PluginProbe
Tag, Category, and Taxonomy Manager – Autotagger Automatically Add Terms / 3.54.0
Tag, Category, and Taxonomy Manager – Autotagger Automatically Add Terms v3.54.0
3.54.0 3.53.0 3.52.0 3.51.0 3.50.0 3.45.0 3.38.0 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.40.0 3.40.1 3.41.0 3.42.0 3.43.0 3.44.0 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 All 178 releases
simple-tags / inc / class.client.autolinks.php

class.client.autolinks.php in Tag, Category, and Taxonomy Manager – Autotagger Automatically Add Terms 3.54.0, at inc/class.client.autolinks.php

1,208 lines 49.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Squiz.PHP.CommentedOutCode.Found,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.Security.NonceVerification.Missing -- Legacy TaxoPress file: keep behavior unchanged while documenting existing PHPCS exceptions.
4
5 class SimpleTags_Client_Autolinks
6 {
7 public static $posts = array();
8 public static $link_tags = array();
9 public static $tagged_link_count = 0;
10
11 /**
12 * Constructor
13 *
14 * @return void
15 * @author WebFactory Ltd
16 */
17 public function __construct()
18 {
19
20 // Register frontend filters regardless of the global setting so a post-level
21 // "enabled" status can force Auto Links on.
22 add_filter('the_posts', array(__CLASS__, 'the_posts'), 10);
23 add_filter('the_content', array(__CLASS__, 'taxopress_autolinks_the_content'), 5);
24 add_filter('the_title', array(__CLASS__, 'taxopress_autolinks_the_title'), 5);
25
26 // Elementor compatibility: elementor outputs content through its own filters,
27 // so also run our autolinks on those outputs.
28 if (defined('ELEMENTOR_VERSION') || class_exists('\Elementor\Plugin')) {
29 add_filter('elementor/frontend/the_content', array(__CLASS__, 'taxopress_autolinks_the_content'), 5);
30 add_filter('elementor/frontend/builder_content', array(__CLASS__, 'taxopress_autolinks_the_content'), 5);
31 }
32
33 if (1 === (int) SimpleTags_Plugin::get_option_value('active_auto_links')) {
34 add_action('admin_init', [$this, 'taxopress_customurl_taxonomies_fields']);
35 }
36 }
37
38 public function taxopress_customurl_taxonomies_fields()
39 {
40
41 if (!$this->is_taxonomy_page()) {
42 return;
43 }
44
45 // Cache the autolink settings to avoid repeated database calls
46 static $enabled_taxonomies = null;
47
48 if ($enabled_taxonomies === null) {
49 $autolink_settings = taxopress_get_autolink_data();
50 $enabled_taxonomies = $this->get_enabled_taxonomies($autolink_settings);
51 }
52
53 // Only get taxonomies that are actually enabled
54 $taxonomies = get_taxonomies([], 'objects');
55
56 foreach ($enabled_taxonomies as $taxonomy_name) {
57 if (isset($taxonomies[$taxonomy_name])) {
58 add_action("{$taxonomy_name}_edit_form_fields", [$this, 'taxopress_add_custom_url_field']);
59 add_action("{$taxonomy_name}_add_form_fields", [$this, 'taxopress_add_custom_url_field_new']);
60 add_action("edited_{$taxonomy_name}", [$this, 'taxopress_save_custom_url_field']);
61 add_action("created_{$taxonomy_name}", [$this, 'taxopress_save_custom_url_field']);
62 }
63 }
64 }
65
66 private function is_taxonomy_page()
67 {
68 global $pagenow;
69 return in_array($pagenow, ['edit-tags.php', 'term.php']);
70 }
71
72 private function get_enabled_taxonomies($autolink_settings)
73 {
74 $default_enabled_taxonomies = ['post_tag', 'category'];
75 $enabled_taxonomies = [];
76 $has_setting_saved = false;
77
78 foreach ($autolink_settings as $setting) {
79 if (array_key_exists('enable_customurl_field', $setting)) {
80 $has_setting_saved = true;
81 $enabled_taxonomies = is_array($setting['enable_customurl_field']) ? $setting['enable_customurl_field'] : [];
82 break;
83 }
84 }
85
86 return $has_setting_saved ? array_unique($enabled_taxonomies) : $default_enabled_taxonomies;
87 }
88
89 /**
90 * Stock posts ID as soon as possible
91 * TODO: test if post_type allow post_tag before keep post ID
92 *
93 * @param array $posts
94 *
95 * @return array
96 */
97 public static function the_posts($posts)
98 {
99 if (!empty($posts) && is_array($posts)) {
100 foreach ((array) $posts as $post) {
101 self::$posts[] = (int) $post->ID;
102 }
103
104 self::$posts = array_unique(self::$posts);
105 }
106
107 return $posts;
108 }
109
110 /**
111 * Get tags from current post views
112 *
113 * @return array
114 */
115 public static function get_tags_from_current_posts($options = false)
116 {
117
118 if (is_array(self::$posts) && count(self::$posts) > 0) {
119 // Generate SQL from post id
120 $postlist = implode("', '", self::$posts);
121
122 // Generate key cache
123 $key = md5(maybe_serialize($postlist));
124
125 $results = array();
126
127 if ($options) {
128 $term_taxonomy = $options['taxonomy'];
129 } else {
130 $term_taxonomy = 'post_tag';
131 }
132
133 // Get cache if exist
134 $cache = wp_cache_get('generate_keywords', 'simple-tags');
135 if ($options || false === $cache) {
136 if ($cache === false) {
137 $cache = [];
138 }
139 foreach (self::$posts as $object_id) {
140 // Get terms
141 $terms = get_object_term_cache($object_id, $term_taxonomy);
142 if (false === $terms || is_wp_error($terms)) {
143 $terms = wp_get_object_terms($object_id, $term_taxonomy);
144 }
145
146 if (false !== $terms && !is_wp_error($terms)) {
147 $results = array_merge($results, $terms);
148 }
149 }
150
151 $cache[$key] = $results;
152 wp_cache_set('generate_keywords', $cache, 'simple-tags');
153 } else {
154 if (isset($cache[$key])) {
155 return $cache[$key];
156 }
157 }
158
159 return $results;
160 }
161
162 return array();
163 }
164
165 /**
166 * Get all available local tags
167 *
168 * @return array
169 */
170 public static function get_all_post_tags($options = false)
171 {
172 if (is_array(self::$posts) && count(self::$posts) > 0) {
173 // Generate SQL from post id
174 $postlist = implode("', '", self::$posts);
175
176 // Generate key cache
177 $key = md5(maybe_serialize($postlist));
178
179 if ($options) {
180 $term_taxonomy = $options['taxonomy'];
181 } else {
182 $term_taxonomy = 'post_tag';
183 }
184 $results = get_tags(['taxonomy' => $term_taxonomy, 'hide_empty' => false]);
185 // Get cache if exist
186 $cache = wp_cache_get('generate_keywords', 'simple-tags');
187 if ($options || false === $cache) {
188 foreach (self::$posts as $object_id) {
189 // Get terms
190 $terms = get_object_term_cache($object_id, $term_taxonomy);
191 if (false === $terms || is_wp_error($terms)) {
192 $terms = wp_get_object_terms($object_id, $term_taxonomy);
193 }
194
195 if (false !== $terms && !is_wp_error($terms)) {
196 $results = array_merge($results, $terms);
197 }
198 }
199 $cache = [];
200 $cache[$key] = $results;
201 wp_cache_set('generate_keywords', $cache, 'simple-tags');
202 } else {
203 if (isset($cache[$key])) {
204 return $cache[$key];
205 }
206 }
207
208 return $results;
209 }
210
211 return array();
212 }
213
214 public function taxopress_add_custom_url_field($term)
215 {
216
217 if (!is_object($term)) {
218 return;
219 }
220
221 $taxopress_custom_url = get_term_meta($term->term_id, 'taxopress_custom_url', true);
222
223 ?>
224 <tr class="form-field">
225 <th scope="row" valign="top">
226 <label for="taxopress_custom_url"><?php esc_html_e('Custom URL', 'simple-tags'); ?></label>
227 </th>
228 <td>
229 <input type="text" name="taxopress_custom_url" id="taxopress_custom_url" value="<?php echo esc_attr($taxopress_custom_url); ?>" size="40">
230 <p class="description">
231 <?php esc_html_e('Enter a custom URL for this term. This URL will only be used for auto-linked terms. If left empty, the term will link to its archive page.', 'simple-tags'); ?>
232 </p>
233
234 </td>
235 </tr>
236 <?php
237 }
238
239 public function taxopress_add_custom_url_field_new()
240 {
241 ?>
242 <div class="form-field">
243 <label for="taxopress_custom_url"><?php esc_html_e('Custom URL', 'simple-tags'); ?></label>
244 <input type="text" name="taxopress_custom_url" id="taxopress_custom_url" value="" size="40">
245 <p class="description">
246 <?php esc_html_e('Enter a custom URL for this term. This URL will only be used if the term is auto-linked. If left empty, the term will link to its archive page.', 'simple-tags'); ?>
247 </p>
248 </div>
249 <?php
250 }
251
252 public function taxopress_save_custom_url_field($term_id)
253 {
254 if (!empty($_POST['taxopress_custom_url'])) {
255 $taxopress_custom_url = sanitize_url(wp_unslash($_POST['taxopress_custom_url']));
256 update_term_meta($term_id, 'taxopress_custom_url', $taxopress_custom_url);
257 } else {
258 delete_term_meta($term_id, 'taxopress_custom_url');
259 }
260 }
261
262 /**
263 * Get links for each tag for auto link feature
264 *
265 */
266 public static function prepare_auto_link_tags($options = false)
267 {
268 global $post;
269 if ($options) {
270 $auto_link_min = (int) $options['autolink_usage_min'];
271 $unattached_terms = (int) $options['unattached_terms'];
272 $autolink_min_char = (int) $options['autolink_min_char'];
273 $autolink_max_char = (int) $options['autolink_max_char'];
274 $term_taxonomy = $options['taxonomy'];
275 $custom_urls_enabled = !empty($options['enable_custom_urls']);
276 } else {
277 $auto_link_min = (int) SimpleTags_Plugin::get_option_value('auto_link_min');
278 $unattached_terms = (int) SimpleTags_Plugin::get_option_value('auto_link_all');
279 $autolink_min_char = 0;
280 $autolink_max_char = 0;
281 $term_taxonomy = 'post_tag';
282 $custom_urls_enabled = false;
283 $autolink_settings = taxopress_get_autolink_data();
284 foreach ($autolink_settings as $setting) {
285 if (!empty($setting['enable_custom_urls'])) {
286 $custom_urls_enabled = true;
287 break;
288 }
289 }
290 }
291
292 if (1 === $unattached_terms) {
293 $terms = self::get_all_post_tags($options);
294 } else {
295 $terms = self::get_tags_from_current_posts($options);
296 }
297
298 $custom_urls_enabled = !empty($options['enable_custom_urls']);
299 $archivepage = !empty($options['archivepage']);
300
301 if (!$archivepage && !$custom_urls_enabled) {
302 self::$link_tags = [];
303 return true;
304 }
305
306 $custom_urls = [];
307 if ($custom_urls_enabled && !empty($terms)) {
308 $term_ids = wp_list_pluck($terms, 'term_id');
309 $custom_urls = self::get_term_meta_batch($term_ids, 'taxopress_custom_url');
310 }
311
312 foreach ((array) $terms as $term) {
313 //hidden terms should not be auto linked
314 if ((int) SimpleTags_Plugin::get_option_value('enable_hidden_terms') === 1) {
315 $min_usage = (int) SimpleTags_Plugin::get_option_value('hide-rarely');
316 if ($term->count < $min_usage) {
317 continue;
318 }
319 }
320
321 if ($options && isset($options['autolink_usage_min'])) {
322 $autolink_min_usage = (int) $options['autolink_usage_min'];
323 if ($term->count < $autolink_min_usage) {
324 continue;
325 }
326 }
327
328 if (!$archivepage && $custom_urls_enabled) {
329 $taxopress_custom_url = isset($custom_urls[$term->term_id])
330 ? $custom_urls[$term->term_id]
331 : '';
332 if (!empty($taxopress_custom_url)) {
333 self::$link_tags[$term->name] = esc_url($taxopress_custom_url);
334 }
335 } else {
336 //add primary term
337 if ($custom_urls_enabled) {
338 $taxopress_custom_url = isset($custom_urls[$term->term_id])
339 ? $custom_urls[$term->term_id]
340 : '';
341 $primary_term_link = !empty($taxopress_custom_url) ? esc_url($taxopress_custom_url) : get_term_link($term, $term->taxonomy);
342 } else {
343 $primary_term_link = get_term_link($term, $term->taxonomy);
344 }
345 $add_terms = [];
346 // store the URL
347 self::$link_tags[$term->name] = $primary_term_link;
348 $add_terms[$term->name] = $primary_term_link;
349
350 // add term synonyms
351 if (is_array($options) && isset($options['synonyms_link']) && (int)$options['synonyms_link'] > 0) {
352 $term_synonyms = taxopress_get_term_synonyms($term->term_id);
353 if (!empty($term_synonyms)) {
354 foreach ($term_synonyms as $term_synonym) {
355 $add_terms[$term_synonym] = $primary_term_link;
356 }
357 }
358 }
359
360 // add linked term
361 $add_terms = taxopress_add_linked_term_options($add_terms, $term->name, $term->taxonomy, true);
362
363 foreach ($add_terms as $add_name => $add_term_link) {
364 //min character check
365 $min_char_pass = true;
366 if ($autolink_min_char > 0) {
367 $min_char_pass = strlen($add_name) >= $autolink_min_char ? true : false;
368 }
369 //max character check
370 $max_char_pass = true;
371 if ($autolink_max_char > 0) {
372 $max_char_pass = strlen($add_name) <= $autolink_max_char ? true : false;
373 }
374
375 if ($auto_link_min === 0 || $term->count >= $auto_link_min && $min_char_pass && $max_char_pass) {
376 self::$link_tags[$add_name] = esc_url($add_term_link);
377 }
378 }
379 }
380 }
381 return true;
382 }
383
384 private static function prepare_excluded_terms($exclude_terms)
385 {
386 $prepared_terms = [];
387 $exclude_terms = explode(',', (string) $exclude_terms);
388
389 foreach ($exclude_terms as $exclude_term) {
390 $exclude_term = trim(stripslashes($exclude_term));
391
392 if ('' === $exclude_term) {
393 continue;
394 }
395
396 $prepared_terms[] = $exclude_term;
397 }
398
399 return array_unique($prepared_terms);
400 }
401
402 /**
403 * Helper function to batch load term meta to reduce database queries
404 */
405 private static function get_term_meta_batch($term_ids, $meta_key)
406 {
407 global $wpdb;
408
409 if (empty($term_ids)) {
410 return [];
411 }
412
413 // Sanitize term IDs
414 $term_ids = array_map('intval', $term_ids);
415 $term_ids_string = implode(',', $term_ids);
416
417 $results = $wpdb->get_results($wpdb->prepare(
418 "SELECT term_id, meta_value FROM {$wpdb->termmeta}
419 WHERE term_id IN ($term_ids_string) AND meta_key = %s",
420 $meta_key
421 ));
422
423 $batch_data = [];
424 foreach ($results as $result) {
425 $batch_data[$result->term_id] = $result->meta_value;
426 }
427
428 return $batch_data;
429 }
430
431 private static function taxopress_get_title_attribute($search, $url, $options)
432 {
433
434 $title_attribute = isset($options['autolink_title_attribute'])
435 ? $options['autolink_title_attribute']
436 : SimpleTags_Plugin::get_option_value('auto_link_title');
437
438 $term = get_term_by('name', $search, $options['taxonomy']);
439 if ($term) {
440 $custom_url = get_term_meta($term->term_id, 'taxopress_custom_url', true);
441 if (!empty($custom_url) && esc_url($custom_url) === $url) {
442 $title_attribute = isset($options['autolink_title_attribute_when_using_custom_url'])
443 ? $options['autolink_title_attribute_when_using_custom_url']
444 : SimpleTags_Plugin::get_option_value('auto_link_title_custom_url');
445 }
446 }
447
448 return esc_attr(sprintf($title_attribute, $search));
449 }
450
451 /**
452 * Replace text by link, except HTML tag, and already text into link, use DOMdocument.
453 * https://stackoverflow.com/questions/4044812/regex-domdocument-match-and-replace-text-not-in-a-link
454 *
455 * @param string $content
456 * @param string $search
457 * @param string $replace
458 * @param string $case
459 * @param string $rel
460 *
461 * @return void
462 */
463 private static function replace_by_links_dom(&$content, $search = '', $replace = '', $case = '', $rel = '', $options = false, $content_type = 'content')
464 {
465 global $post, $autolinked_contents;
466
467 if (!is_object($post) || !isset($post->ID) || empty($content)) {
468 return $content;
469 }
470
471 $dom = new DOMDocument();
472
473 if (!is_array($search)) {
474 $search_lists = [];
475 $search_lists[] = [
476 'term_name' => $search,
477 'term_link' => $replace,
478 'case' => $case,
479 'rel' => $rel,
480 'options' => $options,
481 'option_id' => 0,
482 'option_idxx' => 0,
483 'post_limit' => SimpleTags_Plugin::get_option_value('auto_link_max_by_post'),
484 'term_limit' => SimpleTags_Plugin::get_option_value('auto_link_max_by_tag'),
485 'type' => $content_type,
486 ];
487 } else {
488 $search_lists = $search;
489 }
490
491 if (empty($search_lists)) {
492 return;
493 }
494
495 $content_type = $search_lists[0]['type'];
496
497 $content_key = $content_type . '_' . $post->ID;
498
499 if (!is_array($autolinked_contents)) {
500 $autolinked_contents = [];
501 }
502
503
504 if (isset($autolinked_contents[$content_key])) {
505 $content = $autolinked_contents[$content_key];
506 return $content;
507 }
508
509 // process blocks exclusion
510 if (is_array($search_lists[0]['options']) && !empty($search_lists[0]['options']['blocks_exclusion'])) {
511 $blocks_exclusion = $search_lists[0]['options']['blocks_exclusion'];
512 $process = function ($item) {
513 // Replace "core/" prefixes
514 $item = str_replace("core/", "", $item);
515 // Add "wp:" prefixes
516 return "wp:" . $item;
517 };
518 // Apply the processing function to each element of the array
519 $blocks_to_exclude = array_map($process, $blocks_exclusion);
520
521 // Escape special characters for regex pattern
522 $escaped_blocks = array_map(function ($block) {
523 return preg_quote($block, '/');
524 }, $blocks_to_exclude);
525
526 // Create a regex pattern from the block names
527 $pattern = '/<!-- (' . implode('|', $escaped_blocks) . ')(.*?)-->(.*?)<!-- \/wp\:(.*?) -->/s';
528
529 $content = preg_replace_callback(
530 $pattern,
531 function ($matches) {
532 // Return the matched block content wrapped in taxopressnotag
533 return '<taxopressnotag>' . $matches[0] . '</taxopressnotag>';
534 },
535 $content
536 );
537 }
538
539 // process shortcodes exclusion
540 if (is_array($search_lists[0]['options']) && !empty($search_lists[0]['options']['shortcodes_exclusion'])) {
541 $shortcodes_exclusion = $search_lists[0]['options']['shortcodes_exclusion'];
542
543 // Create a regex pattern to match any of the shortcodes with or without attributes
544 $pattern = '/\[(?:' . implode('|', $shortcodes_exclusion) . ')[^\]]*\]/';
545
546 // Define the replacement function to wrap matched shortcodes
547 $callback = function ($matches) {
548 return '<taxopressnotag>' . $matches[0] . '</taxopressnotag>';
549 };
550
551 // Apply the regex pattern to the content using the callback
552 $content = preg_replace_callback($pattern, $callback, $content);
553 }
554
555 //replace html entity with their entity code
556 foreach (taxopress_html_character_and_entity() as $enity => $code) {
557 $content = str_replace($enity, $code, $content);
558 }
559
560 // Replace HTML entities with placeholders
561 $content = preg_replace_callback('/&#(\d+);/', function ($matches) {
562 return 'STARTTAXOPRESSENTITY' . $matches[1] . 'TAXOPRESSENTITYEND';
563 }, $content);
564
565 //$content = str_replace('&#','|--|',$content);//https://github.com/TaxoPress/TaxoPress/issues/824
566 //$content = str_replace('&','&#38;',$content); //https://github.com/TaxoPress/TaxoPress/issues/770*/
567 $content = 'starttaxopressrandom' . $content . 'endtaxopressrandom'; //we're having issue when content start with styles https://wordpress.org/support/topic/3-7-2-auto-link-case-not-working/#post-16665257
568 //$content = utf8_decode($content);
569
570 libxml_use_internal_errors(true);
571 // loadXml needs properly formatted documents, so it's better to use loadHtml, but it needs a hack to properly handle UTF-8 encoding
572 //$result = $dom->loadHtml(mb_convert_encoding($content, 'HTML-ENTITIES', "UTF-8"));
573 //$result = $dom->loadHtml(htmlspecialchars_decode($content, ENT_QUOTES | ENT_HTML5));
574
575 // Load the content as HTML without adding DOCTYPE and html/body tags
576 $content = '<div>' . $content . '</div>';
577
578 $content = mb_encode_numericentity($content, [0x80, 0x10FFFF, 0, 0xFFFFF], 'UTF-8');
579
580 $result = $dom->loadHTML($content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
581
582 if (false === $result) {
583 return;
584 }
585
586 $xpath = new DOMXPath($dom);
587 $j = 0;
588 $replaced_count = 0;
589
590 $replaced_tags_counts = [];
591 $option_limits = [];
592 $term_limits = [];
593 $option_remaining = [];
594 $option_tagged_counts = [];
595 $node_text = [];
596
597 foreach ($search_lists as $search_details) {
598 $search = $search_details['term_name'];
599 $replace = $search_details['term_link'];
600 $case = $search_details['case'];
601 $rel = $search_details['rel'];
602 $options = $search_details['options'];
603
604 $search = str_replace('&amp;', 'taxopressamp', $search); // https://github.com/TaxoPress/TaxoPress/issues/1638
605
606 if (is_array($options)) {
607 $autolink_case = $options['autolink_case'];
608 $html_exclusion = $options['html_exclusion'];
609 $html_exclusion_customs = isset($options['html_exclusion_customs']) ? $options['html_exclusion_customs'] : [];
610 $exclude_class = $options['autolink_exclude_class'];
611 $title_attribute = $options['autolink_title_attribute'];
612 $title_attribute_custom_url = $options['autolink_title_attribute_when_using_custom_url'];
613 $link_class = isset($options['link_class']) ? taxopress_format_class($options['link_class']) : '';
614 } else {
615 $autolink_case = 'lowercase';
616 $html_exclusion = [];
617 $html_exclusion_customs = [];
618 $exclude_class = '';
619 $title_attribute = SimpleTags_Plugin::get_option_value('auto_link_title');
620 $title_attribute_custom_url = SimpleTags_Plugin::get_option_value('auto_link_title_custom_url');
621 $link_class = '';
622 }
623
624 $detail_id = $search_details['type'] . '_' . $search_details['option_id'];
625
626 if (!isset($option_limits[$detail_id])) {
627 $option_limits[$detail_id] = $search_details['post_limit'];
628 }
629
630 if (!isset($option_remaining[$detail_id])) {
631 $option_remaining[$detail_id] = $option_limits[$detail_id];
632 }
633
634 if (!isset($term_limits[$detail_id])) {
635 $term_limits[$detail_id] = min($search_details['term_limit'], $option_remaining[$detail_id]);
636 }
637
638 if (!isset($option_tagged_counts[$detail_id])) {
639 $option_tagged_counts[$detail_id] = 0;
640 }
641
642 $html_exclusion[] = 'taxopressnotag';
643 $html_exclusion[] = 'meta';
644 $html_exclusion[] = 'link';
645 $html_exclusion[] = 'head';
646
647 if (!empty($html_exclusion_customs)) {
648 $html_exclusion = array_merge($html_exclusion, $html_exclusion_customs);
649 }
650
651 //auto link exclusion
652 $exclusion = '[not(ancestor::a)][not(ancestor-or-self::a/@*)]';
653 if (count($html_exclusion) > 0) {
654 foreach ($html_exclusion as $exclude_ancestor) {
655 $exclusion .= '[not(ancestor::' . strtolower($exclude_ancestor) . ')][not(ancestor-or-self::' . strtolower($exclude_ancestor) . '/@*)]';
656 }
657 }
658
659 // Prepare exclude terms array
660 $excludes_class = explode(',', $exclude_class);
661 if (!empty($excludes_class)) {
662 $excludes_class = array_filter($excludes_class);
663 $excludes_class = array_unique($excludes_class);
664 if (count($excludes_class) > 0) {
665 foreach ($excludes_class as $idclass) {
666 if (substr(trim($idclass), 0, 1) === "#") {
667 $element_id = ltrim(trim($idclass), "#");
668 $exclusion .= "[not(ancestor::*[@id='$element_id'])]";
669 } else {
670 $element_class = ltrim(trim($idclass), ".");
671 $exclusion .= "[not(ancestor::*[@class='$element_class'])]";
672 }
673 }
674 }
675 }
676
677 foreach ($xpath->query('//text()' . $exclusion . '') as $node) {
678 // Exclude URLs from being replaced
679 if (preg_match('/(http|https):\/\/[^\s]+/i', $node->wholeText)) {
680 continue;
681 }
682 $url = array_key_exists($search, self::$link_tags) ? self::$link_tags[$search] : $replace;
683 $used_title = self::taxopress_get_title_attribute($search, $url, $options);
684 $replace = $url;
685 $substitute = '<a href="' . $replace . '" class="st_tag internal_tag ' . $link_class . '" ' . $rel . ' title="' . $used_title . "\">$search</a>";
686 $link_openeing = '<a href="' . $replace . '" class="st_tag internal_tag ' . $link_class . '" ' . $rel . ' title="' . $used_title . "\">";
687 $link_closing = '</a>';
688 $upperterm = strtoupper($search);
689 $lowerterm = strtolower($search);
690
691
692
693 if ($option_limits[$detail_id] > 0 && 0 >= $option_remaining[$detail_id]) {
694 break;
695 }
696
697 if ($term_limits[$detail_id] > 0 && array_key_exists($replace, $replaced_tags_counts) && $replaced_tags_counts[$replace] >= $term_limits[$detail_id]) {
698 continue;
699 }
700
701 if ($term_limits[$detail_id] > 0 && array_key_exists($replace, $replaced_tags_counts)) {
702 $same_usage_max = min($term_limits[$detail_id] - $replaced_tags_counts[$replace], $option_remaining[$detail_id]);
703 } else {
704 $same_usage_max = min($term_limits[$detail_id], $option_remaining[$detail_id]);
705 }
706
707 // Replace HTML entities with placeholders in term name too to match them in content
708 $search = preg_replace_callback('/&#(\d+);/', function ($matches) {
709 return 'STARTTAXOPRESSENTITY' . $matches[1] . 'TAXOPRESSENTITYEND';
710 }, $search);
711
712 $whole_words = isset($options['whole_words']) ? (int)$options['whole_words'] : 1;
713 if ($whole_words) {
714 $pattern = '/\b' . preg_quote($search, "/") . '\b/ui';
715 } else {
716 // Allow partial matches - no word boundary checks
717 $pattern = '/' . preg_quote($search, "/") . '/i';
718 }
719
720 //if ('i' === $case) {
721 if ($autolink_case === 'none') { // retain case
722 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $link_closing) {
723 return $link_openeing . htmlspecialchars($matches[0]) . $link_closing;
724 }, $node->wholeText, $same_usage_max, $rep_count);
725 } elseif ($autolink_case === 'uppercase') { // uppercase
726 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $upperterm, $link_closing) {
727 return $link_openeing . strtoupper($matches[0]) . $link_closing;
728 }, $node->wholeText, $same_usage_max, $rep_count);
729 } elseif ($autolink_case === 'termcase') { // termcase
730 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $search, $link_closing) {
731 return $link_openeing . $search . $link_closing;
732 }, $node->wholeText, $same_usage_max, $rep_count);
733 } else { // lowercase
734 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $lowerterm, $link_closing) {
735 return $link_openeing . strtolower($matches[0]) . $link_closing;
736 }, $node->wholeText, $same_usage_max, $rep_count);
737 }
738
739 if ($replaced && !empty(trim($replaced))) {
740 $j++;
741 if ($rep_count > 0) {
742 // TODO : Think about synonyms
743 if (array_key_exists($replace, $replaced_tags_counts)) {
744 $replaced_tags_counts[$replace] = $replaced_tags_counts[$replace] + $rep_count;
745 } else {
746 $replaced_tags_counts[$replace] = $rep_count;
747 }
748 $option_tagged_counts[$detail_id] = $option_tagged_counts[$detail_id] + $rep_count;
749 $option_remaining[$detail_id] = $option_limits[$detail_id] - $option_tagged_counts[$detail_id];
750 }
751 }
752 $newNode = $dom->createDocumentFragment();
753 $newNode->appendXML($replaced);
754
755 $node->parentNode->replaceChild($newNode, $node);
756 if ($option_remaining[$detail_id] === 0) {
757 break;
758 }
759 }
760 }
761
762
763 // Get the innerHTML of the root div, excluding the div itself
764 $content = '';
765 foreach ($dom->documentElement->childNodes as $node) {
766 $content .= $dom->saveHTML($node);
767 }
768
769 // Add back the starting "&#"
770 $content = str_replace('STARTTAXOPRESSENTITY', '&#', $content);
771 // Add back the ending ";"
772 $content = str_replace('TAXOPRESSENTITYEND', ';', $content);
773
774 // get only the body tag with its contents, then trim the body tag itself to get only the original content
775 //$content = mb_substr($dom->saveHTML($xpath->query('//body')->item(0)), 6, -7, "UTF-8");
776 $content = str_replace('|--|', '&#', $content); //https://github.com/TaxoPress/TaxoPress/issues/824
777 /**
778 * I commented the line below because of https://github.com/TaxoPress/TaxoPress/issues/2118
779 * In summary, when content contain < and > special character which are intentiona;, they're been
780 * changed to < > which is not needed
781 */
782 //$content = str_replace('&#60;', '<', $content);
783 //$content = str_replace('&#62;', '>', $content);
784
785 foreach (taxopress_html_character_and_entity(true) as $enity => $code) {
786 $content = str_replace($enity, $code, $content);
787 }
788
789 $content = str_replace('&amp ;rsquo;', '&rsquo;', $content);
790 $content = str_replace(['', '', '&rsquor;', ' &rsquor;', '&rsquo;', ' &rsquo;'], '\'', $content);
791
792 $content = str_replace('&#38;', '&', $content); //https://github.com/TaxoPress/TaxoPress/issues/770
793 $content = str_replace(';amp;', ';', $content); //https://github.com/TaxoPress/TaxoPress/issues/810
794 $content = str_replace('%7C--%7C038;', '&', $content); //https://github.com/TaxoPress/TaxoPress/issues/1377
795
796 $content = str_replace('starttaxopressrandom', '', $content);
797 $content = str_replace('endtaxopressrandom', '', $content);
798 // replace <taxopressnotag> added to skip certain elements
799 $content = str_replace('<taxopressnotag>', '', $content);
800 $content = str_replace('</taxopressnotag>', '', $content);
801 }
802
803 /**
804 * Replace text by link, except HTML tag, and already text into link, use PregEXP.
805 *
806 * @param string $content
807 * @param string $search
808 * @param string $replace
809 * @param string $case
810 * @param string $rel
811 */
812 private static function replace_by_links_regexp(&$content, $search = '', $replace = '', $case = '', $rel = '', $options = false)
813 {
814
815 if ($options) {
816 $autolink_case = $options['autolink_case'];
817 $html_exclusion = $options['html_exclusion'];
818 $html_exclusion_customs = isset($options['html_exclusion_customs']) ? $options['html_exclusion_customs'] : [];
819 $exclude_class = $options['autolink_exclude_class'];
820 $title_attribute = $options['autolink_title_attribute'];
821 $title_attribute_custom_url = $options['autolink_title_attribute_when_using_custom_url'];
822 $same_usage_max = $options['autolink_same_usage_max'];
823 $max_by_post = $options['autolink_usage_max'];
824 $link_class = isset($options['link_class']) ? taxopress_format_class($options['link_class']) : '';
825 } else {
826 $autolink_case = 'lowercase';
827 $html_exclusion = [];
828 $html_exclusion_customs = [];
829 $exclude_class = '';
830 $title_attribute = SimpleTags_Plugin::get_option_value('auto_link_title');
831 $title_attribute_custom_url = SimpleTags_Plugin::get_option_value('auto_link_title_custom_url');
832 $same_usage_max = SimpleTags_Plugin::get_option_value('auto_link_max_by_tag');
833 $max_by_post = SimpleTags_Plugin::get_option_value('auto_link_max_by_post');
834 $link_class = '';
835 }
836
837
838 if (!empty($html_exclusion_customs)) {
839 $html_exclusion = array_merge($html_exclusion, $html_exclusion_customs);
840 }
841
842 $must_tokenize = true; // will perform basic tokenization
843 $tokens = null; // two kinds of tokens: markup and text
844
845 $j = 0;
846 $filtered = ''; // will filter text token by token
847
848 // Get URL and title first
849 $url = array_key_exists($search, self::$link_tags) ? self::$link_tags[$search] : $replace;
850 $used_title = self::taxopress_get_title_attribute($search, $url, $options);
851 $replace = $url;
852
853 // Determine word boundary pattern based on whole_words setting
854 $whole_words = isset($options['whole_words']) ? (int)$options['whole_words'] : 1;
855 if ($whole_words) {
856 // Use \b for strict whole word matching
857 $match = '/\b' . preg_quote($search, '/') . '\b/u' . $case;
858 $substitute = '<a href="' . $replace . '" class="st_tag internal_tag ' . $link_class . '" ' . $rel . ' title="' . $used_title . "\">$0</a>";
859 } else {
860 // Allow partial matches - no word boundary checks
861 $match = '/(' . preg_quote($search, '/') . ')/u' . $case;
862 $substitute = '<a href="' . $replace . '" class="st_tag internal_tag ' . $link_class . '" ' . $rel . ' title="' . $used_title . "\">$1</a>";
863 }
864
865 //$match = "/\b" . preg_quote($search, "/") . "\b/".$case;
866 //$substitute = '<a href="'.$replace.'" class="st_tag internal_tag '.$link_class.'" '.$rel.' title="'. esc_attr( sprintf( __('Posts tagged with %s', 'simple-tags'), $search ) )."\">$0</a>";
867 // for efficiency only tokenize if forced to do so
868 if ($must_tokenize) {
869 // this regexp is taken from PHP Markdown by Michel Fortin: http://www.michelf.com/projects/php-markdown/
870 $comment = '(?s:<!(?:--.*?--\s*)+>)|';
871 $processing_instruction = '(?s:<\?.*?\?>)|';
872 $tag = '(?:<[/!$]?[-a-zA-Z0-9:]+\b(?>[^"\'>]+|"[^"]*"|\'[^\']*\')*>)';
873
874 $markup = $comment . $processing_instruction . $tag;
875 $flags = PREG_SPLIT_DELIM_CAPTURE;
876 $tokens = preg_split("{($markup)}", $content, -1, $flags);
877 $must_tokenize = false;
878 }
879
880 // there should always be at least one token, but check just in case
881 $anchor_level = 0;
882
883 if (isset($tokens) && is_array($tokens) && count($tokens) > 0) {
884 $i = 0;
885 $ancestor = '';
886 foreach ($tokens as $token) {
887 if (++$i % 2 && $token !== '') { // this token is (non-markup) text
888 $pass_check = true;
889
890 if (!empty(trim($ancestor))) {
891 //auto link exclusion
892 if (count($html_exclusion) > 0) {
893 foreach ($html_exclusion as $exclude_ancestor) {
894 if (taxopress_starts_with($ancestor, '<' . strtolower($exclude_ancestor) . '')) {
895 $pass_check = false;
896 break;
897 }
898 }
899 }
900
901
902 // Prepare exclude terms array
903 $excludes_class = explode(',', $exclude_class);
904 if (!empty($excludes_class)) {
905 $excludes_class = array_filter($excludes_class);
906 $excludes_class = array_unique($excludes_class);
907 if (count($excludes_class) > 0) {
908 foreach ($excludes_class as $idclass) {
909 if (substr(trim($idclass), 0, 1) === "#") {
910 $div_id = ltrim(trim($idclass), "#");
911 if (preg_match_all('/<[a-z \'"]*id="' . $div_id . '"/i', $ancestor, $matches) || preg_match_all('/<[a-z \'"]*id=\'' . $div_id . '\'/i', $ancestor, $matches)) {
912 $pass_check = false;
913 break;
914 }
915 } else {
916 $div_class = ltrim(trim($idclass), ".");
917 if (preg_match_all('/<[a-z ]*class="' . $div_class . '"/i', $ancestor, $matches) || preg_match_all('/<[a-z ]*class=\'' . $div_class . '\'/i', $ancestor, $matches)) {
918 $pass_check = false;
919 break;
920 }
921 }
922 }
923 }
924 }
925 }
926 if ($anchor_level === 0 && $pass_check) { // linkify if not inside anchor tags
927 if (preg_match($match, $token)) { // use preg_match for compatibility with PHP 4
928 $j++;
929
930
931 $remaining_usage = $max_by_post - self::$tagged_link_count;
932 if ($same_usage_max > $remaining_usage) {
933 $same_usage_max = $remaining_usage;
934 }
935
936
937 if ($same_usage_max > 0) { // Limit replacement at 1 by default, or options value !
938 $token = preg_replace($match, $substitute, $token, $same_usage_max, $rep_count); // only PHP 5 supports calling preg_replace with 5 arguments
939 self::$tagged_link_count = self::$tagged_link_count + $rep_count;
940 }
941 $must_tokenize = true; // re-tokenize next time around
942 }
943 }
944 } else { // this token is markup
945 if (preg_match("#<\s*a\s+[^>]*>#i", $token)) { // found <a ...>
946 $ancestor = $token;
947 $anchor_level++;
948 } elseif (preg_match("#<\s*/\s*a\s*>#i", $token)) { // found </a>
949 $anchor_level--;
950 } elseif (taxopress_starts_with($token, "</")) {
951 $ancestor = '';
952 } else {
953 $ancestor = $token;
954 }
955 }
956 $filtered .= $token; // this token has now been filtered
957 }
958 $content = $filtered; // filtering completed for this link
959 }
960 }
961
962
963 /**
964 * Replace text by link to tag
965 *
966 * @param string $content
967 *
968 * @return string
969 */
970 public static function taxopress_autolinks_the_content($content = '')
971 {
972 global $post;
973
974
975 // Some Elementor render flows may call filters with no global $post set.
976 // Try to recover a post object before bailing out.
977 if (!is_object($post)) {
978 $post = get_post();
979 }
980
981
982 if (!is_object($post) || is_admin()) {
983 return $content;
984 }
985
986 $post_tags = taxopress_get_autolink_data();
987
988 // User preference for this post?
989 $feature_status = self::get_post_feature_status($post->ID);
990 if (
991 'disabled' === $feature_status
992 || ('default' === $feature_status && 1 !== (int) SimpleTags_Plugin::get_option_value('active_auto_links'))
993 ) {
994 return $content;
995 }
996
997 $force_enabled = 'enabled' === $feature_status;
998
999 if (count($post_tags) > 0) {
1000 $auto_link_replace = [];
1001 foreach ($post_tags as $post_tag) {
1002 // Get option
1003 $embedded = (isset($post_tag['embedded']) && is_array($post_tag['embedded']) && count($post_tag['embedded']) > 0) ? $post_tag['embedded'] : false;
1004
1005 if (!$force_enabled && (!$embedded || !in_array($post->post_type, $embedded, true))) {
1006 continue;
1007 }
1008
1009 if ($post_tag['autolink_display'] === 'post_title') {
1010 continue;
1011 }
1012
1013 //reset tags just in case
1014 self::$link_tags = [];
1015 // Get currents tags if no exists
1016 self::prepare_auto_link_tags($post_tag);
1017
1018 // Shuffle array
1019 SimpleTags_Client::random_array(self::$link_tags);
1020
1021 // HTML Rel (tag/no-follow)
1022 $rel = SimpleTags_Client::get_rel_attribut();
1023
1024 // only continue if the database actually returned any links
1025 if (!isset(self::$link_tags) || !is_array(self::$link_tags) || empty(self::$link_tags)) {
1026 $can_continue = false;
1027 } else {
1028 $can_continue = true;
1029 }
1030
1031 if ($can_continue) {
1032 // Case option ?
1033 $case = (1 === (int) $post_tag['ignore_case']) ? 'i' : '';
1034 $strpos_fnc = ('i' === $case) ? 'stripos' : 'strpos';
1035
1036 $excludes_terms = self::prepare_excluded_terms($post_tag['auto_link_exclude']);
1037
1038 $z = 0;
1039
1040 foreach ((array) self::$link_tags as $term_name => $term_link) {
1041 $z++;
1042 // Force string for tags "number"
1043 $term_name = (string) $term_name;
1044
1045 // Exclude terms ? next...
1046 if (taxopress_in_array_i($term_name, (array) $excludes_terms, true)) {
1047 continue;
1048 }
1049
1050 // Make a first test with PHP function, economize CPU with regexp
1051 if (false === $strpos_fnc($post->post_content, $term_name)) {
1052 continue;
1053 }
1054
1055 $auto_link_replace[] = [
1056 'term_name' => $term_name,
1057 'term_link' => $term_link,
1058 'case' => $case,
1059 'rel' => $rel,
1060 'options' => $post_tag,
1061 'option_id' => $post_tag['ID'],
1062 'post_limit' => $post_tag['autolink_usage_max'],
1063 'term_limit' => $post_tag['autolink_same_usage_max'],
1064 'type' => 'content',
1065 ];
1066
1067 if (!class_exists('DOMDocument') || !class_exists('DOMXPath')) {
1068 self::replace_by_links_regexp($content, $term_name, $term_link, $case, $rel, $post_tag);
1069 }
1070 }
1071 }
1072 }
1073 if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
1074 self::replace_by_links_dom($content, $auto_link_replace);
1075 }
1076 }
1077 return $content;
1078 }
1079
1080
1081
1082
1083 /**
1084 * Replace text by link to tag
1085 *
1086 * @param string $title
1087 *
1088 * @return string
1089 */
1090 public static function taxopress_autolinks_the_title($title = '')
1091 {
1092 global $post;
1093
1094 if (!is_object($post) || is_admin()) {
1095 return $title;
1096 }
1097
1098 $post_tags = taxopress_get_autolink_data();
1099
1100
1101 // User preference for this post?
1102 $feature_status = self::get_post_feature_status($post->ID);
1103 if (
1104 'disabled' === $feature_status
1105 || ('default' === $feature_status && 1 !== (int) SimpleTags_Plugin::get_option_value('active_auto_links'))
1106 ) {
1107 return $title;
1108 }
1109
1110 $force_enabled = 'enabled' === $feature_status;
1111
1112 if (count($post_tags) > 0) {
1113 foreach ($post_tags as $post_tag) {
1114 // Get option
1115 $embedded = (isset($post_tag['embedded']) && is_array($post_tag['embedded']) && count($post_tag['embedded']) > 0) ? $post_tag['embedded'] : false;
1116
1117 if (!$force_enabled && (!$embedded || !in_array($post->post_type, $embedded, true))) {
1118 continue;
1119 }
1120
1121 if ($post_tag['autolink_display'] === 'post_content') {
1122 continue;
1123 }
1124 //reset tags just in case
1125 self::$link_tags = [];
1126 // Get currents tags if no exists
1127 self::prepare_auto_link_tags($post_tag);
1128
1129 // Shuffle array
1130 SimpleTags_Client::random_array(self::$link_tags);
1131
1132 // HTML Rel (tag/no-follow)
1133 $rel = SimpleTags_Client::get_rel_attribut();
1134
1135 // only continue if the database actually returned any links
1136 if (!isset(self::$link_tags) || !is_array(self::$link_tags) || empty(self::$link_tags)) {
1137 $can_continue = false;
1138 } else {
1139 $can_continue = true;
1140 }
1141
1142 if ($can_continue) {
1143 // Case option ?
1144 $case = (1 === (int) $post_tag['ignore_case']) ? 'i' : '';
1145 $strpos_fnc = ('i' === $case) ? 'stripos' : 'strpos';
1146
1147 $excludes_terms = self::prepare_excluded_terms($post_tag['auto_link_exclude']);
1148
1149 $z = 0;
1150 $auto_link_replace = [];
1151 foreach ((array) self::$link_tags as $term_name => $term_link) {
1152 $z++;
1153 // Force string for tags "number"
1154 $term_name = (string) $term_name;
1155
1156 // Exclude terms ? next...
1157 if (taxopress_in_array_i($term_name, (array) $excludes_terms, true)) {
1158 continue;
1159 }
1160
1161 // Make a first test with PHP function, economize CPU with regexp
1162 if (false === $strpos_fnc($title, $term_name)) {
1163 continue;
1164 }
1165
1166 $auto_link_replace[] = [
1167 'term_name' => $term_name,
1168 'term_link' => $term_link,
1169 'case' => $case,
1170 'rel' => $rel,
1171 'options' => $post_tag,
1172 'option_id' => $post_tag['ID'],
1173 'post_limit' => $post_tag['autolink_usage_max'],
1174 'term_limit' => $post_tag['autolink_same_usage_max'],
1175 'type' => 'content',
1176 ];
1177
1178 if (!class_exists('DOMDocument') || !class_exists('DOMXPath')) {
1179 self::replace_by_links_regexp($title, $term_name, $term_link, $case, $rel, $post_tag);
1180 }
1181 }
1182 if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
1183 self::replace_by_links_dom($title, $auto_link_replace);
1184 }
1185 }
1186 }
1187 }
1188
1189
1190 return $title;
1191 }
1192
1193 private static function get_post_feature_status($post_id)
1194 {
1195 $status = get_post_meta($post_id, '_taxopress_autolinks_status', true);
1196
1197 if (in_array($status, ['default', 'enabled', 'disabled'], true)) {
1198 return $status;
1199 }
1200
1201 if (get_post_meta($post_id, '_exclude_autolinks', true)) {
1202 return 'disabled';
1203 }
1204
1205 return 'default';
1206 }
1207 }
1208