PluginProbe
Tag, Category, and Taxonomy Manager – Autotagger Automatically Add Terms / trunk
Tag, Category, and Taxonomy Manager – Autotagger Automatically Add Terms vtrunk
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 3.6.2 All 177 releases
simple-tags / inc / class.client.autolinks.php

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

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