PluginProbe
Tag, Category, and Taxonomy Manager – Autotagger Automatically Add Terms / 3.42.0
Tag, Category, and Taxonomy Manager – Autotagger Automatically Add Terms v3.42.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.42.0, at inc/class.client.autolinks.php

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