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

1,203 lines 49.0 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 public static $posts = array();
6 public static $link_tags = array();
7 public static $tagged_link_count = 0;
8
9 /**
10 * Constructor
11 *
12 * @return void
13 * @author WebFactory Ltd
14 */
15 public function __construct()
16 {
17
18 if (1 === (int) SimpleTags_Plugin::get_option_value('active_auto_links')) {
19
20 $auto_link_priority = SimpleTags_Plugin::get_option_value('auto_link_priority');
21 if (0 === (int) $auto_link_priority) {
22 $auto_link_priority = 12;
23 }
24
25 // Auto link tags
26 add_filter('the_posts', array(__CLASS__, 'the_posts'), 10);
27
28 //new UI
29 add_filter('the_content', array(__CLASS__, 'taxopress_autolinks_the_content'), 5);
30 add_filter('the_title', array(__CLASS__, 'taxopress_autolinks_the_title'), 5);
31
32 // Elementor compatibility: elementor outputs content through its own filters,
33 // so also run our autolinks on those outputs.
34 if (defined('ELEMENTOR_VERSION') || class_exists('\Elementor\Plugin')) {
35 add_filter('elementor/frontend/the_content', array(__CLASS__, 'taxopress_autolinks_the_content'), 5);
36 add_filter('elementor/frontend/builder_content', array(__CLASS__, 'taxopress_autolinks_the_content'), 5);
37 }
38
39 add_action('admin_init', [$this, 'taxopress_customurl_taxonomies_fields']);
40 }
41 }
42
43 public function taxopress_customurl_taxonomies_fields()
44 {
45
46 if (!$this->is_taxonomy_page()) {
47 return;
48 }
49
50 // Cache the autolink settings to avoid repeated database calls
51 static $enabled_taxonomies = null;
52
53 if ($enabled_taxonomies === null) {
54 $autolink_settings = taxopress_get_autolink_data();
55 $enabled_taxonomies = $this->get_enabled_taxonomies($autolink_settings);
56 }
57
58 // Only get taxonomies that are actually enabled
59 $taxonomies = get_taxonomies([], 'objects');
60
61 foreach ($enabled_taxonomies as $taxonomy_name) {
62 if (isset($taxonomies[$taxonomy_name])) {
63 add_action("{$taxonomy_name}_edit_form_fields", [$this, 'taxopress_add_custom_url_field']);
64 add_action("{$taxonomy_name}_add_form_fields", [$this, 'taxopress_add_custom_url_field_new']);
65 add_action("edited_{$taxonomy_name}", [$this, 'taxopress_save_custom_url_field']);
66 add_action("created_{$taxonomy_name}", [$this, 'taxopress_save_custom_url_field']);
67 }
68 }
69 }
70
71 private function is_taxonomy_page()
72 {
73 global $pagenow;
74 return in_array($pagenow, ['edit-tags.php', 'term.php']);
75 }
76
77 private function get_enabled_taxonomies($autolink_settings)
78 {
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 /**
95 * Stock posts ID as soon as possible
96 * TODO: test if post_type allow post_tag before keep post ID
97 *
98 * @param array $posts
99 *
100 * @return array
101 */
102 public static function the_posts($posts)
103 {
104 if (!empty($posts) && is_array($posts)) {
105 foreach ((array) $posts as $post) {
106 self::$posts[] = (int) $post->ID;
107 }
108
109 self::$posts = array_unique(self::$posts);
110 }
111
112 return $posts;
113 }
114
115 /**
116 * Get tags from current post views
117 *
118 * @return array
119 */
120 public static function get_tags_from_current_posts($options = false)
121 {
122
123 if (is_array(self::$posts) && count(self::$posts) > 0) {
124 // Generate SQL from post id
125 $postlist = implode("', '", self::$posts);
126
127 // Generate key cache
128 $key = md5(maybe_serialize($postlist));
129
130 $results = array();
131
132 if ($options) {
133 $term_taxonomy = $options['taxonomy'];
134 } else {
135 $term_taxonomy = 'post_tag';
136 }
137
138 // Get cache if exist
139 $cache = wp_cache_get('generate_keywords', 'simple-tags');
140 if ($options || false === $cache) {
141 if ($cache === false) {
142 $cache = [];
143 }
144 foreach (self::$posts as $object_id) {
145 // Get terms
146 $terms = get_object_term_cache($object_id, $term_taxonomy);
147 if (false === $terms || is_wp_error($terms)) {
148 $terms = wp_get_object_terms($object_id, $term_taxonomy);
149 }
150
151 if (false !== $terms && !is_wp_error($terms)) {
152 $results = array_merge($results, $terms);
153 }
154 }
155
156 $cache[$key] = $results;
157 wp_cache_set('generate_keywords', $cache, 'simple-tags');
158 } else {
159 if (isset($cache[$key])) {
160 return $cache[$key];
161 }
162 }
163
164 return $results;
165 }
166
167 return array();
168 }
169
170 /**
171 * Get all available local tags
172 *
173 * @return array
174 */
175 public static function get_all_post_tags($options = false)
176 {
177 if (is_array(self::$posts) && count(self::$posts) > 0) {
178 // Generate SQL from post id
179 $postlist = implode("', '", self::$posts);
180
181 // Generate key cache
182 $key = md5(maybe_serialize($postlist));
183
184 if ($options) {
185 $term_taxonomy = $options['taxonomy'];
186 } else {
187 $term_taxonomy = 'post_tag';
188 }
189 $results = get_tags(['taxonomy' => $term_taxonomy, 'hide_empty' => false]);
190 // Get cache if exist
191 $cache = wp_cache_get('generate_keywords', 'simple-tags');
192 if ($options || false === $cache) {
193 foreach (self::$posts as $object_id) {
194 // Get terms
195 $terms = get_object_term_cache($object_id, $term_taxonomy);
196 if (false === $terms || is_wp_error($terms)) {
197 $terms = wp_get_object_terms($object_id, $term_taxonomy);
198 }
199
200 if (false !== $terms && !is_wp_error($terms)) {
201 $results = array_merge($results, $terms);
202 }
203 }
204 $cache = [];
205 $cache[$key] = $results;
206 wp_cache_set('generate_keywords', $cache, 'simple-tags');
207 } else {
208 if (isset($cache[$key])) {
209 return $cache[$key];
210 }
211 }
212
213 return $results;
214 }
215
216 return array();
217 }
218
219 public function taxopress_add_custom_url_field($term)
220 {
221
222 if (!is_object($term)) {
223 return;
224 }
225
226 $taxopress_custom_url = get_term_meta($term->term_id, 'taxopress_custom_url', true);
227
228 ?>
229 <tr class="form-field">
230 <th scope="row" valign="top">
231 <label for="taxopress_custom_url"><?php esc_html_e('Custom URL', 'simple-tags'); ?></label>
232 </th>
233 <td>
234 <input type="text" name="taxopress_custom_url" id="taxopress_custom_url" value="<?php echo esc_attr($taxopress_custom_url); ?>" size="40">
235 <p class="description">
236 <?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'); ?>
237 </p>
238
239 </td>
240 </tr>
241 <?php
242 }
243
244 public function taxopress_add_custom_url_field_new()
245 {
246 ?>
247 <div class="form-field">
248 <label for="taxopress_custom_url"><?php esc_html_e('Custom URL', 'simple-tags'); ?></label>
249 <input type="text" name="taxopress_custom_url" id="taxopress_custom_url" value="" size="40">
250 <p class="description">
251 <?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'); ?>
252 </p>
253 </div>
254 <?php
255 }
256
257 public function taxopress_save_custom_url_field($term_id)
258 {
259 if (!empty($_POST['taxopress_custom_url'])) {
260 $taxopress_custom_url = sanitize_url(wp_unslash($_POST['taxopress_custom_url']));
261 update_term_meta($term_id, 'taxopress_custom_url', $taxopress_custom_url);
262 } else {
263 delete_term_meta($term_id, 'taxopress_custom_url');
264 }
265
266 }
267
268 /**
269 * Get links for each tag for auto link feature
270 *
271 */
272 public static function prepare_auto_link_tags($options = false)
273 {
274 global $post;
275 if ($options) {
276 $auto_link_min = (int) $options['autolink_usage_min'];
277 $unattached_terms = (int) $options['unattached_terms'];
278 $autolink_min_char = (int) $options['autolink_min_char'];
279 $autolink_max_char = (int) $options['autolink_max_char'];
280 $term_taxonomy = $options['taxonomy'];
281 $custom_urls_enabled = !empty($options['enable_custom_urls']);
282 } else {
283 $auto_link_min = (int) SimpleTags_Plugin::get_option_value('auto_link_min');
284 $unattached_terms = (int) SimpleTags_Plugin::get_option_value('auto_link_all');
285 $autolink_min_char = 0;
286 $autolink_max_char = 0;
287 $term_taxonomy = 'post_tag';
288 $custom_urls_enabled = false;
289 $autolink_settings = taxopress_get_autolink_data();
290 foreach ($autolink_settings as $setting) {
291 if (!empty($setting['enable_custom_urls'])) {
292 $custom_urls_enabled = true;
293 break;
294 }
295 }
296 }
297
298 if (1 === $unattached_terms) {
299 $terms = self::get_all_post_tags($options);
300 } else {
301 $terms = self::get_tags_from_current_posts($options);
302 }
303
304 $custom_urls_enabled = !empty($options['enable_custom_urls']);
305 $archivepage = !empty($options['archivepage']);
306
307 if (!$archivepage && !$custom_urls_enabled) {
308 self::$link_tags = [];
309 return true;
310 }
311
312 $custom_urls = [];
313 if ($custom_urls_enabled && !empty($terms)) {
314 $term_ids = wp_list_pluck($terms, 'term_id');
315 $custom_urls = self::get_term_meta_batch($term_ids, 'taxopress_custom_url');
316 }
317
318 foreach ((array) $terms as $term) {
319
320 //hidden terms should not be auto linked
321 if ((int) SimpleTags_Plugin::get_option_value('enable_hidden_terms') === 1) {
322 $min_usage = (int) SimpleTags_Plugin::get_option_value('hide-rarely');
323 if ($term->count < $min_usage) {
324 continue;
325 }
326 }
327
328 if ($options && isset($options['autolink_usage_min'])) {
329 $autolink_min_usage = (int) $options['autolink_usage_min'];
330 if ($term->count < $autolink_min_usage) {
331 continue;
332 }
333 }
334
335 if (!$archivepage && $custom_urls_enabled) {
336 $taxopress_custom_url = isset($custom_urls[$term->term_id])
337 ? $custom_urls[$term->term_id]
338 : '';
339 if (!empty($taxopress_custom_url)) {
340 self::$link_tags[$term->name] = esc_url($taxopress_custom_url);
341 }
342 } else {
343 //add primary term
344 if ($custom_urls_enabled) {
345 $taxopress_custom_url = isset($custom_urls[$term->term_id])
346 ? $custom_urls[$term->term_id]
347 : '';
348 $primary_term_link = !empty($taxopress_custom_url) ? esc_url($taxopress_custom_url) : get_term_link($term, $term->taxonomy);
349 } else {
350 $primary_term_link = get_term_link($term, $term->taxonomy);
351 }
352 $add_terms = [];
353 // store the URL
354 self::$link_tags[$term->name] = $primary_term_link;
355 $add_terms[$term->name] = $primary_term_link;
356
357 // add term synonyms
358 if (is_array($options) && isset($options['synonyms_link']) && (int)$options['synonyms_link'] > 0) {
359 $term_synonyms = taxopress_get_term_synonyms($term->term_id);
360 if (!empty($term_synonyms)) {
361 foreach ($term_synonyms as $term_synonym) {
362 $add_terms[$term_synonym] = $primary_term_link;
363 }
364 }
365 }
366
367 // add linked term
368 $add_terms = taxopress_add_linked_term_options($add_terms, $term->name, $term->taxonomy, true);
369
370 foreach ($add_terms as $add_name => $add_term_link) {
371 //min character check
372 $min_char_pass = true;
373 if ($autolink_min_char > 0) {
374 $min_char_pass = strlen($add_name) >= $autolink_min_char ? true : false;
375 }
376 //max character check
377 $max_char_pass = true;
378 if ($autolink_max_char > 0) {
379 $max_char_pass = strlen($add_name) <= $autolink_max_char ? true : false;
380 }
381
382 if ($auto_link_min === 0 || $term->count >= $auto_link_min && $min_char_pass && $max_char_pass) {
383 self::$link_tags[$add_name] = esc_url($add_term_link);
384 }
385 }
386 }
387 }
388 return true;
389 }
390
391 /**
392 * Helper function to batch load term meta to reduce database queries
393 */
394 private static function get_term_meta_batch($term_ids, $meta_key)
395 {
396 global $wpdb;
397
398 if (empty($term_ids)) {
399 return [];
400 }
401
402 // Sanitize term IDs
403 $term_ids = array_map('intval', $term_ids);
404 $term_ids_string = implode(',', $term_ids);
405
406 $results = $wpdb->get_results($wpdb->prepare(
407 "SELECT term_id, meta_value FROM {$wpdb->termmeta}
408 WHERE term_id IN ($term_ids_string) AND meta_key = %s",
409 $meta_key
410 ));
411
412 $batch_data = [];
413 foreach ($results as $result) {
414 $batch_data[$result->term_id] = $result->meta_value;
415 }
416
417 return $batch_data;
418 }
419
420 private static function taxopress_get_title_attribute($search, $url, $options)
421 {
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 $whole_words = isset($options['whole_words']) ? (int)$options['whole_words'] : 1;
703 if ($whole_words) {
704 $pattern = '/\b' . preg_quote($search, "/") . '\b/ui';
705 } else {
706 // Allow partial matches - no word boundary checks
707 $pattern = '/' . preg_quote($search, "/") . '/i';
708 }
709
710 //if ('i' === $case) {
711 if ($autolink_case === 'none') { // retain case
712 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $link_closing) {
713 return $link_openeing . htmlspecialchars($matches[0]) . $link_closing;
714 }, $node->wholeText, $same_usage_max, $rep_count);
715 } elseif ($autolink_case === 'uppercase') { // uppercase
716 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $upperterm, $link_closing) {
717 return $link_openeing . strtoupper($matches[0]) . $link_closing;
718 }, $node->wholeText, $same_usage_max, $rep_count);
719 } elseif ($autolink_case === 'termcase') { // termcase
720 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $search, $link_closing) {
721 return $link_openeing . $search . $link_closing;
722 }, $node->wholeText, $same_usage_max, $rep_count);
723 } else { // lowercase
724 $replaced = preg_replace_callback($pattern, function ($matches) use ($link_openeing, $lowerterm, $link_closing) {
725 return $link_openeing . strtolower($matches[0]) . $link_closing;
726 }, $node->wholeText, $same_usage_max, $rep_count);
727 }
728
729 if ($replaced && !empty(trim($replaced))) {
730 $j++;
731 if ($rep_count > 0) {
732 // TODO : Think about synonyms
733 if (array_key_exists($replace, $replaced_tags_counts)) {
734 $replaced_tags_counts[$replace] = $replaced_tags_counts[$replace] + $rep_count;
735 } else {
736 $replaced_tags_counts[$replace] = $rep_count;
737 }
738 $option_tagged_counts[$detail_id] = $option_tagged_counts[$detail_id] + $rep_count;
739 $option_remaining[$detail_id] = $option_limits[$detail_id] - $option_tagged_counts[$detail_id];
740 }
741 }
742 $newNode = $dom->createDocumentFragment();
743 $newNode->appendXML($replaced);
744
745 $node->parentNode->replaceChild($newNode, $node);
746 if ($option_remaining[$detail_id] === 0) {
747 break;
748 }
749 }
750 }
751
752
753 // Get the innerHTML of the root div, excluding the div itself
754 $content = '';
755 foreach ($dom->documentElement->childNodes as $node) {
756 $content .= $dom->saveHTML($node);
757 }
758
759 // Add back the starting "&#"
760 $content = str_replace('STARTTAXOPRESSENTITY', '&#', $content);
761 // Add back the ending ";"
762 $content = str_replace('TAXOPRESSENTITYEND', ';', $content);
763
764 // get only the body tag with its contents, then trim the body tag itself to get only the original content
765 //$content = mb_substr($dom->saveHTML($xpath->query('//body')->item(0)), 6, -7, "UTF-8");
766 $content = str_replace('|--|', '&#', $content); //https://github.com/TaxoPress/TaxoPress/issues/824
767 /**
768 * I commented the line below because of https://github.com/TaxoPress/TaxoPress/issues/2118
769 * In summary, when content contain < and > special character which are intentiona;, they're been
770 * changed to < > which is not needed
771 */
772 //$content = str_replace('&#60;', '<', $content);
773 //$content = str_replace('&#62;', '>', $content);
774
775 foreach (taxopress_html_character_and_entity(true) as $enity => $code) {
776 $content = str_replace($enity, $code, $content);
777 }
778
779 $content = str_replace('&amp ;rsquo;', '&rsquo;', $content);
780 $content = str_replace(['’', ' ’', '&rsquor;', ' &rsquor;', '&rsquo;', ' &rsquo;'], '\'', $content);
781
782 $content = str_replace('&#38;', '&', $content); //https://github.com/TaxoPress/TaxoPress/issues/770
783 $content = str_replace(';amp;', ';', $content); //https://github.com/TaxoPress/TaxoPress/issues/810
784 $content = str_replace('%7C--%7C038;', '&', $content); //https://github.com/TaxoPress/TaxoPress/issues/1377
785
786 $content = str_replace('starttaxopressrandom', '', $content);
787 $content = str_replace('endtaxopressrandom', '', $content);
788 // replace <taxopressnotag> added to skip certain elements
789 $content = str_replace('<taxopressnotag>', '', $content);
790 $content = str_replace('</taxopressnotag>', '', $content);
791
792 }
793
794 /**
795 * Replace text by link, except HTML tag, and already text into link, use PregEXP.
796 *
797 * @param string $content
798 * @param string $search
799 * @param string $replace
800 * @param string $case
801 * @param string $rel
802 */
803 private static function replace_by_links_regexp(&$content, $search = '', $replace = '', $case = '', $rel = '', $options = false)
804 {
805
806 if ($options) {
807 $autolink_case = $options['autolink_case'];
808 $html_exclusion = $options['html_exclusion'];
809 $html_exclusion_customs = isset($options['html_exclusion_customs']) ? $options['html_exclusion_customs'] : [];
810 $exclude_class = $options['autolink_exclude_class'];
811 $title_attribute = $options['autolink_title_attribute'];
812 $title_attribute_custom_url = $options['autolink_title_attribute_when_using_custom_url'];
813 $same_usage_max = $options['autolink_same_usage_max'];
814 $max_by_post = $options['autolink_usage_max'];
815 $link_class = isset($options['link_class']) ? taxopress_format_class($options['link_class']) : '';
816 } else {
817 $autolink_case = 'lowercase';
818 $html_exclusion = [];
819 $html_exclusion_customs = [];
820 $exclude_class = '';
821 $title_attribute = SimpleTags_Plugin::get_option_value('auto_link_title');
822 $title_attribute_custom_url = SimpleTags_Plugin::get_option_value('auto_link_title_custom_url');
823 $same_usage_max = SimpleTags_Plugin::get_option_value('auto_link_max_by_tag');
824 $max_by_post = SimpleTags_Plugin::get_option_value('auto_link_max_by_post');
825 $link_class = '';
826 }
827
828
829 if (!empty($html_exclusion_customs)) {
830 $html_exclusion = array_merge($html_exclusion, $html_exclusion_customs);
831 }
832
833 $must_tokenize = true; // will perform basic tokenization
834 $tokens = null; // two kinds of tokens: markup and text
835
836 $j = 0;
837 $filtered = ''; // will filter text token by token
838
839 // Get URL and title first
840 $url = array_key_exists($search, self::$link_tags) ? self::$link_tags[$search] : $replace;
841 $used_title = self::taxopress_get_title_attribute($search, $url, $options);
842 $replace = $url;
843
844 // Determine word boundary pattern based on whole_words setting
845 $whole_words = isset($options['whole_words']) ? (int)$options['whole_words'] : 1;
846 if ($whole_words) {
847 // Use \b for strict whole word matching
848 $match = '/\b' . preg_quote($search, '/') . '\b/u' . $case;
849 $substitute = '<a href="' . $replace . '" class="st_tag internal_tag ' . $link_class . '" ' . $rel . ' title="' . $used_title . "\">$0</a>";
850 } else {
851 // Allow partial matches - no word boundary checks
852 $match = '/(' . preg_quote($search, '/') . ')/u' . $case;
853 $substitute = '<a href="' . $replace . '" class="st_tag internal_tag ' . $link_class . '" ' . $rel . ' title="' . $used_title . "\">$1</a>";
854 }
855
856 //$match = "/\b" . preg_quote($search, "/") . "\b/".$case;
857 //$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>";
858 // for efficiency only tokenize if forced to do so
859 if ($must_tokenize) {
860 // this regexp is taken from PHP Markdown by Michel Fortin: http://www.michelf.com/projects/php-markdown/
861 $comment = '(?s:<!(?:--.*?--\s*)+>)|';
862 $processing_instruction = '(?s:<\?.*?\?>)|';
863 $tag = '(?:<[/!$]?[-a-zA-Z0-9:]+\b(?>[^"\'>]+|"[^"]*"|\'[^\']*\')*>)';
864
865 $markup = $comment . $processing_instruction . $tag;
866 $flags = PREG_SPLIT_DELIM_CAPTURE;
867 $tokens = preg_split("{($markup)}", $content, -1, $flags);
868 $must_tokenize = false;
869 }
870
871 // there should always be at least one token, but check just in case
872 $anchor_level = 0;
873
874 if (isset($tokens) && is_array($tokens) && count($tokens) > 0) {
875 $i = 0;
876 $ancestor = '';
877 foreach ($tokens as $token) {
878 if (++$i % 2 && $token !== '') { // this token is (non-markup) text
879
880
881 $pass_check = true;
882
883 if (!empty(trim($ancestor))) {
884
885 //auto link exclusion
886 if (count($html_exclusion) > 0) {
887 foreach ($html_exclusion as $exclude_ancestor) {
888 if (taxopress_starts_with($ancestor, '<' . strtolower($exclude_ancestor) . '')) {
889 $pass_check = false;
890 break;
891 }
892 }
893 }
894
895
896 // Prepare exclude terms array
897 $excludes_class = explode(',', $exclude_class);
898 if (!empty($excludes_class)) {
899 $excludes_class = array_filter($excludes_class);
900 $excludes_class = array_unique($excludes_class);
901 if (count($excludes_class) > 0) {
902 foreach ($excludes_class as $idclass) {
903 if (substr(trim($idclass), 0, 1) === "#") {
904 $div_id = ltrim(trim($idclass), "#");
905 if (preg_match_all('/<[a-z \'"]*id="' . $div_id . '"/i', $ancestor, $matches) || preg_match_all('/<[a-z \'"]*id=\'' . $div_id . '\'/i', $ancestor, $matches)) {
906 $pass_check = false;
907 break;
908 }
909 } else {
910 $div_class = ltrim(trim($idclass), ".");
911 if (preg_match_all('/<[a-z ]*class="' . $div_class . '"/i', $ancestor, $matches) || preg_match_all('/<[a-z ]*class=\'' . $div_class . '\'/i', $ancestor, $matches)) {
912 $pass_check = false;
913 break;
914 }
915 }
916 }
917 }
918 }
919 }
920 if ($anchor_level === 0 && $pass_check) { // linkify if not inside anchor tags
921 if (preg_match($match, $token)) { // use preg_match for compatibility with PHP 4
922 $j++;
923
924
925 $remaining_usage = $max_by_post - self::$tagged_link_count;
926 if ($same_usage_max > $remaining_usage) {
927 $same_usage_max = $remaining_usage;
928 }
929
930
931 if ($same_usage_max > 0) { // Limit replacement at 1 by default, or options value !
932 $token = preg_replace($match, $substitute, $token, $same_usage_max, $rep_count); // only PHP 5 supports calling preg_replace with 5 arguments
933 self::$tagged_link_count = self::$tagged_link_count + $rep_count;
934 }
935 $must_tokenize = true; // re-tokenize next time around
936 }
937 }
938 } else { // this token is markup
939 if (preg_match("#<\s*a\s+[^>]*>#i", $token)) { // found <a ...>
940 $ancestor = $token;
941 $anchor_level++;
942 } elseif (preg_match("#<\s*/\s*a\s*>#i", $token)) { // found </a>
943 $anchor_level--;
944 } elseif (taxopress_starts_with($token, "</")) {
945 $ancestor = '';
946 } else {
947 $ancestor = $token;
948 }
949 }
950 $filtered .= $token; // this token has now been filtered
951 }
952 $content = $filtered; // filtering completed for this link
953 }
954 }
955
956
957 /**
958 * Replace text by link to tag
959 *
960 * @param string $content
961 *
962 * @return string
963 */
964 public static function taxopress_autolinks_the_content($content = '')
965 {
966 global $post;
967
968
969 // Some Elementor render flows may call filters with no global $post set.
970 // Try to recover a post object before bailing out.
971 if (!is_object($post)) {
972 $post = get_post();
973 }
974
975
976 if (!is_object($post) || is_admin()) {
977 return $content;
978 }
979
980 $post_tags = taxopress_get_autolink_data();
981
982 // user preference for this post ?
983 $meta_value = get_post_meta($post->ID, '_exclude_autolinks', true);
984 if (!empty($meta_value)) {
985 return $content;
986 }
987
988 if (count($post_tags) > 0) {
989 $auto_link_replace = [];
990 foreach ($post_tags as $post_tag) {
991
992 // Get option
993 $embedded = (isset($post_tag['embedded']) && is_array($post_tag['embedded']) && count($post_tag['embedded']) > 0) ? $post_tag['embedded'] : false;
994
995 if (!$embedded) {
996 continue;
997 }
998
999 if (!in_array($post->post_type, $embedded)) {
1000 continue;
1001 }
1002
1003 if ($post_tag['autolink_display'] === 'post_title') {
1004 continue;
1005 }
1006
1007 //reset tags just in case
1008 self::$link_tags = [];
1009 // Get currents tags if no exists
1010 self::prepare_auto_link_tags($post_tag);
1011
1012 // Shuffle array
1013 SimpleTags_Client::random_array(self::$link_tags);
1014
1015 // HTML Rel (tag/no-follow)
1016 $rel = SimpleTags_Client::get_rel_attribut();
1017
1018 // only continue if the database actually returned any links
1019 if (!isset(self::$link_tags) || !is_array(self::$link_tags) || empty(self::$link_tags)) {
1020 $can_continue = false;
1021 } else {
1022 $can_continue = true;
1023 }
1024
1025 if ($can_continue) {
1026 // Case option ?
1027 $case = (1 === (int) $post_tag['ignore_case']) ? 'i' : '';
1028 $strpos_fnc = ('i' === $case) ? 'stripos' : 'strpos';
1029
1030 // Prepare exclude terms array
1031 $excludes_terms = explode(',', $post_tag['auto_link_exclude']);
1032 if (empty($excludes_terms)) {
1033 $excludes_terms = array();
1034 } else {
1035 $excludes_terms = array_filter($excludes_terms, '_delete_empty_element');
1036 $excludes_terms = array_unique($excludes_terms);
1037 }
1038
1039 $z = 0;
1040
1041 foreach ((array) self::$link_tags as $term_name => $term_link) {
1042 $z++;
1043 // Force string for tags "number"
1044 $term_name = (string) $term_name;
1045
1046 // Exclude terms ? next...
1047 if (taxopress_in_array_i($term_name, (array) $excludes_terms, true)) {
1048 continue;
1049 }
1050
1051 // Make a first test with PHP function, economize CPU with regexp
1052 if (false === $strpos_fnc($post->post_content, $term_name)) {
1053 continue;
1054 }
1055
1056 $auto_link_replace[] = [
1057 'term_name' => $term_name,
1058 'term_link' => $term_link,
1059 'case' => $case,
1060 'rel' => $rel,
1061 'options' => $post_tag,
1062 'option_id' => $post_tag['ID'],
1063 'post_limit' => $post_tag['autolink_usage_max'],
1064 'term_limit' => $post_tag['autolink_same_usage_max'],
1065 'type' => 'content',
1066 ];
1067
1068 if (!class_exists('DOMDocument') || !class_exists('DOMXPath')) {
1069 self::replace_by_links_regexp($content, $term_name, $term_link, $case, $rel, $post_tag);
1070 }
1071 }
1072 }
1073 }
1074 if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
1075 self::replace_by_links_dom($content, $auto_link_replace);
1076 }
1077 }
1078 return $content;
1079 }
1080
1081
1082
1083
1084 /**
1085 * Replace text by link to tag
1086 *
1087 * @param string $title
1088 *
1089 * @return string
1090 */
1091 public static function taxopress_autolinks_the_title($title = '')
1092 {
1093 global $post;
1094
1095 if (!is_object($post) || is_admin()) {
1096 return $title;
1097 }
1098
1099 $post_tags = taxopress_get_autolink_data();
1100
1101
1102 // user preference for this post ?
1103 $meta_value = get_post_meta($post->ID, '_exclude_autolinks', true);
1104 if (!empty($meta_value)) {
1105 return $title;
1106 }
1107
1108 if (count($post_tags) > 0) {
1109
1110 foreach ($post_tags as $post_tag) {
1111
1112 // Get option
1113 $embedded = (isset($post_tag['embedded']) && is_array($post_tag['embedded']) && count($post_tag['embedded']) > 0) ? $post_tag['embedded'] : false;
1114
1115 if (!$embedded) {
1116 continue;
1117 }
1118
1119 if (!in_array($post->post_type, $embedded)) {
1120 continue;
1121 }
1122
1123 if ($post_tag['autolink_display'] === 'post_content') {
1124 continue;
1125 }
1126 //reset tags just in case
1127 self::$link_tags = [];
1128 // Get currents tags if no exists
1129 self::prepare_auto_link_tags($post_tag);
1130
1131 // Shuffle array
1132 SimpleTags_Client::random_array(self::$link_tags);
1133
1134 // HTML Rel (tag/no-follow)
1135 $rel = SimpleTags_Client::get_rel_attribut();
1136
1137 // only continue if the database actually returned any links
1138 if (!isset(self::$link_tags) || !is_array(self::$link_tags) || empty(self::$link_tags)) {
1139 $can_continue = false;
1140 } else {
1141 $can_continue = true;
1142 }
1143
1144 if ($can_continue) {
1145
1146 // Case option ?
1147 $case = (1 === (int) $post_tag['ignore_case']) ? 'i' : '';
1148 $strpos_fnc = ('i' === $case) ? 'stripos' : 'strpos';
1149
1150 // Prepare exclude terms array
1151 $excludes_terms = explode(',', $post_tag['auto_link_exclude']);
1152 if (empty($excludes_terms)) {
1153 $excludes_terms = array();
1154 } else {
1155 $excludes_terms = array_filter($excludes_terms, '_delete_empty_element');
1156 $excludes_terms = array_unique($excludes_terms);
1157 }
1158
1159 $z = 0;
1160 $auto_link_replace = [];
1161 foreach ((array) self::$link_tags as $term_name => $term_link) {
1162 $z++;
1163 // Force string for tags "number"
1164 $term_name = (string) $term_name;
1165
1166 // Exclude terms ? next...
1167 if (taxopress_in_array_i($term_name, (array) $excludes_terms, true)) {
1168 continue;
1169 }
1170
1171 // Make a first test with PHP function, economize CPU with regexp
1172 if (false === $strpos_fnc($title, $term_name)) {
1173 continue;
1174 }
1175
1176 $auto_link_replace[] = [
1177 'term_name' => $term_name,
1178 'term_link' => $term_link,
1179 'case' => $case,
1180 'rel' => $rel,
1181 'options' => $post_tag,
1182 'option_id' => $post_tag['ID'],
1183 'post_limit' => $post_tag['autolink_usage_max'],
1184 'term_limit' => $post_tag['autolink_same_usage_max'],
1185 'type' => 'content',
1186 ];
1187
1188 if (!class_exists('DOMDocument') || !class_exists('DOMXPath')) {
1189 self::replace_by_links_regexp($title, $term_name, $term_link, $case, $rel, $post_tag);
1190 }
1191 }
1192 if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
1193 self::replace_by_links_dom($title, $auto_link_replace);
1194 }
1195 }
1196 }
1197 }
1198
1199
1200 return $title;
1201 }
1202 }
1203