Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.12% covered (danger)
19.12%
169 / 884
4.76% covered (danger)
4.76%
1 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
fulltext_native
19.12% covered (danger)
19.12%
169 / 884
4.76% covered (danger)
4.76%
1 / 21
30717.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_available
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_search_query
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_common_words
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_word_length
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 split_keywords
72.89% covered (warning)
72.89%
121 / 166
0.00% covered (danger)
0.00%
0 / 1
149.17
 keyword_search
0.00% covered (danger)
0.00%
0 / 256
0.00% covered (danger)
0.00%
0 / 1
3192
 author_search
0.00% covered (danger)
0.00%
0 / 160
0.00% covered (danger)
0.00%
0 / 1
1560
 supports_phrase_search
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 index
0.00% covered (danger)
0.00%
0 / 98
0.00% covered (danger)
0.00%
0 / 1
272
 index_remove
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
42
 tidy
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 delete_index
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 index_created
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 index_stats
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_stats
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 split_message
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 cleanup
51.43% covered (warning)
51.43%
36 / 70
0.00% covered (danger)
0.00%
0 / 1
65.84
 get_acp_options
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb\search\backend;
15
16use phpbb\config\config;
17use phpbb\db\driver\driver_interface;
18use phpbb\db\tools\tools_interface;
19use phpbb\event\dispatcher_interface;
20use phpbb\language\language;
21use phpbb\user;
22
23/**
24* phpBB's own db driven fulltext search, version 2
25*/
26class fulltext_native extends base implements search_backend_interface
27{
28    protected const UTF8_HANGUL_FIRST = "\xEA\xB0\x80";
29    protected const UTF8_HANGUL_LAST = "\xED\x9E\xA3";
30    protected const UTF8_CJK_FIRST = "\xE4\xB8\x80";
31    protected const UTF8_CJK_LAST = "\xE9\xBE\xBB";
32    protected const UTF8_CJK_B_FIRST = "\xF0\xA0\x80\x80";
33    protected const UTF8_CJK_B_LAST = "\xF0\xAA\x9B\x96";
34
35    /**
36     * Associative array holding index stats
37     * @var array
38     */
39    protected $stats = array();
40
41    /**
42     * Associative array stores the min and max word length to be searched
43     * @var array
44     */
45    protected $word_length = array();
46
47    /**
48     * Contains tidied search query.
49     * Operators are prefixed in search query and common words excluded
50     * @var string
51     */
52    protected $search_query = '';
53
54    /**
55     * Contains common words.
56     * Common words are words with length less/more than min/max length
57     * @var array
58     */
59    protected $common_words = array();
60
61    /**
62     * Post ids of posts containing words that are to be included
63     * @var array
64     */
65    protected $must_contain_ids = array();
66
67    /**
68     * Post ids of posts containing words that should not be included
69     * @var array
70     */
71    protected $must_not_contain_ids = array();
72
73    /**
74     * Post ids of posts containing at least one word that needs to be excluded
75     * @var array
76     */
77    protected $must_exclude_one_ids = array();
78
79    /**
80     * Relative path to board root
81     * @var string
82     */
83    protected $phpbb_root_path;
84
85    /**
86     * PHP Extension
87     * @var string
88     */
89    protected $php_ext;
90
91    /**
92     * DBAL tools
93     * @var tools_interface
94     */
95    protected $db_tools;
96
97    /**
98     * phpBB event dispatcher object
99     * @var dispatcher_interface
100     */
101    protected $phpbb_dispatcher;
102
103    /** @var language */
104    protected $language;
105
106    /** @var string */
107    protected $search_wordlist_table;
108
109    /** @var string */
110    protected $search_wordmatch_table;
111
112    /**
113     * Initialises the fulltext_native search backend with min/max word length
114     *
115     * @param config                $config                Config object
116     * @param driver_interface        $db                    Database object
117     * @param tools_interface        $db_tools            Database tools
118     * @param dispatcher_interface    $phpbb_dispatcher    Event dispatcher object
119     * @param language                $language
120     * @param user                    $user                User object
121     * @param string                $search_results_table
122     * @param string                $search_wordlist_table
123     * @param string                $search_wordmatch_table
124     * @param string                $phpbb_root_path    phpBB root path
125     * @param string                $phpEx                PHP file extension
126     */
127    public function __construct(config $config, driver_interface $db, tools_interface $db_tools, dispatcher_interface $phpbb_dispatcher,
128                                language $language, user $user, string $search_results_table, string $search_wordlist_table,
129                                string $search_wordmatch_table, string $phpbb_root_path, string $phpEx)
130    {
131        global $cache;
132
133        parent::__construct($cache, $config, $db, $user, $search_results_table);
134        $this->db_tools = $db_tools;
135        $this->phpbb_dispatcher = $phpbb_dispatcher;
136        $this->language = $language;
137
138        $this->search_wordlist_table = $search_wordlist_table;
139        $this->search_wordmatch_table = $search_wordmatch_table;
140
141        $this->phpbb_root_path = $phpbb_root_path;
142        $this->php_ext = $phpEx;
143
144        $this->word_length = array('min' => (int) $this->config['fulltext_native_min_chars'], 'max' => (int) $this->config['fulltext_native_max_chars']);
145
146        /**
147        * Load the UTF tools
148        */
149        if (!function_exists('utf8_decode_ncr'))
150        {
151            include($this->phpbb_root_path . 'includes/utf/utf_tools.' . $this->php_ext);
152        }
153    }
154
155    /**
156     * {@inheritdoc}
157    */
158    public function get_name(): string
159    {
160        return 'phpBB Native Fulltext';
161    }
162
163    /**
164     * {@inheritdoc}
165     */
166    public function is_available(): bool
167    {
168        return true;
169    }
170
171    /**
172     * {@inheritdoc}
173     */
174    public function init()
175    {
176        return false;
177    }
178
179    /**
180     * {@inheritdoc}
181     */
182    public function get_search_query(): string
183    {
184        return $this->search_query;
185    }
186
187    /**
188     * {@inheritdoc}
189     */
190    public function get_common_words(): array
191    {
192        return $this->common_words;
193    }
194
195    /**
196     * {@inheritdoc}
197     */
198    public function get_word_length()
199    {
200        return $this->word_length;
201    }
202
203    /**
204     * {@inheritdoc}
205     */
206    public function split_keywords(string &$keywords, string $terms): bool
207    {
208        $tokens = '+-|()* ';
209
210        $keywords = trim($this->cleanup($keywords, $tokens));
211
212        // allow word|word|word without brackets
213        if ((strpos($keywords, ' ') === false) && (strpos($keywords, '|') !== false) && (strpos($keywords, '(') === false))
214        {
215            $keywords = '(' . $keywords . ')';
216        }
217
218        $open_bracket = $space = false;
219        for ($i = 0, $n = strlen($keywords); $i < $n; $i++)
220        {
221            if ($open_bracket !== false)
222            {
223                switch ($keywords[$i])
224                {
225                    case ')':
226                        if ($open_bracket + 1 == $i)
227                        {
228                            $keywords[$i - 1] = '|';
229                            $keywords[$i] = '|';
230                        }
231                        $open_bracket = false;
232                    break;
233                    case '(':
234                        $keywords[$i] = '|';
235                    break;
236                    case '+':
237                    case '-':
238                    case ' ':
239                        $keywords[$i] = '|';
240                    break;
241                    case '*':
242                        // $i can never be 0 here since $open_bracket is initialised to false
243                        if (strpos($tokens, $keywords[$i - 1]) !== false && ($i + 1 === $n || strpos($tokens, $keywords[$i + 1]) !== false))
244                        {
245                            $keywords[$i] = '|';
246                        }
247                    break;
248                }
249            }
250            else
251            {
252                switch ($keywords[$i])
253                {
254                    case ')':
255                        $keywords[$i] = ' ';
256                    break;
257                    case '(':
258                        $open_bracket = $i;
259                        $space = false;
260                    break;
261                    case '|':
262                        $keywords[$i] = ' ';
263                    break;
264                    case '-':
265                        // Ignore hyphen if followed by a space
266                        if (isset($keywords[$i + 1]) && $keywords[$i + 1] == ' ')
267                        {
268                            $keywords[$i] = ' ';
269                        }
270                        else
271                        {
272                            $space = $keywords[$i];
273                        }
274                    break;
275                    case '+':
276                        $space = $keywords[$i];
277                    break;
278                    case ' ':
279                        if ($space !== false)
280                        {
281                            $keywords[$i] = $space;
282                        }
283                    break;
284                    default:
285                        $space = false;
286                }
287            }
288        }
289
290        if ($open_bracket !== false)
291        {
292            $keywords .= ')';
293        }
294
295        $match = array(
296            '#  +#',
297            '#\|\|+#',
298            '#(\+|\-)(?:\+|\-)+#',
299            '#\(\|#',
300            '#\|\)#',
301        );
302        $replace = array(
303            ' ',
304            '|',
305            '$1',
306            '(',
307            ')',
308        );
309
310        $keywords = preg_replace($match, $replace, $keywords);
311
312        // Ensure a space exists before +, - and | to make the split and count work correctly
313        $countable_keywords = preg_replace('/(?<!\s)(\+|\-|\|)/', ' $1', $keywords);
314
315        $num_keywords = count(explode(' ', $countable_keywords));
316
317        // We limit the number of allowed keywords to minimize load on the database
318        if ($this->config['max_num_search_keywords'] && $num_keywords > $this->config['max_num_search_keywords'])
319        {
320            trigger_error($this->language->lang('MAX_NUM_SEARCH_KEYWORDS_REFINE', (int) $this->config['max_num_search_keywords'], $num_keywords));
321        }
322
323        // $keywords input format: each word separated by a space, words in a bracket are not separated
324
325        // the user wants to search for any word, convert the search query
326        if ($terms == 'any')
327        {
328            $words = array();
329
330            preg_match_all('#([^\\s+\\-|()]+)(?:$|[\\s+\\-|()])#u', $keywords, $words);
331            if (count($words[1]))
332            {
333                $keywords = '(' . implode('|', $words[1]) . ')';
334            }
335        }
336
337        // Remove non trailing wildcards from each word to prevent a full table scan (it's now using the database index)
338        $match = '#\*(?!$|\s)#';
339        $replace = '$1';
340        $keywords = preg_replace($match, $replace, $keywords);
341
342        // Only allow one wildcard in the search query to limit the database load
343        $match = '#\*#';
344        $replace = '$1';
345        $count_wildcards = substr_count($keywords, '*');
346
347        // Reverse the string to remove all wildcards except the first one
348        $keywords = strrev(preg_replace($match, $replace, strrev($keywords), $count_wildcards - 1));
349        unset($count_wildcards);
350
351        // set the search_query which is shown to the user
352        $this->search_query = $keywords;
353
354        $exact_words = array();
355        preg_match_all('#([^\\s+\\-|()]+)(?:$|[\\s+\\-|()])#u', $keywords, $exact_words);
356        $exact_words = $exact_words[1];
357
358        $common_ids = $words = array();
359
360        if (count($exact_words))
361        {
362            $sql = 'SELECT word_id, word_text, word_common
363                FROM ' . $this->search_wordlist_table . '
364                WHERE ' . $this->db->sql_in_set('word_text', $exact_words) . '
365                ORDER BY word_count ASC';
366            $result = $this->db->sql_query($sql);
367
368            // store an array of words and ids, remove common words
369            while ($row = $this->db->sql_fetchrow($result))
370            {
371                if ($row['word_common'])
372                {
373                    $this->common_words[] = $row['word_text'];
374                    $common_ids[$row['word_text']] = (int) $row['word_id'];
375                    continue;
376                }
377
378                $words[$row['word_text']] = (int) $row['word_id'];
379            }
380            $this->db->sql_freeresult($result);
381        }
382
383        // Handle +, - without preceding whitespace character
384        $match        = array('#(\S)\+#', '#(\S)-#');
385        $replace    = array('$1 +', '$1 +');
386
387        $keywords = preg_replace($match, $replace, $keywords);
388
389        // now analyse the search query, first split it using the spaces
390        $query = explode(' ', $keywords);
391
392        $this->must_contain_ids = array();
393        $this->must_not_contain_ids = array();
394        $this->must_exclude_one_ids = array();
395
396        foreach ($query as $word)
397        {
398            if (empty($word))
399            {
400                continue;
401            }
402
403            // words which should not be included
404            if ($word[0] == '-')
405            {
406                $word = substr($word, 1);
407
408                // a group of which at least one may not be in the resulting posts
409                if (isset($word[0]) && $word[0] == '(')
410                {
411                    $word = array_unique(explode('|', substr($word, 1, -1)));
412                    $mode = 'must_exclude_one';
413                }
414                // one word which should not be in the resulting posts
415                else
416                {
417                    $mode = 'must_not_contain';
418                }
419                $ignore_no_id = true;
420            }
421            // words which have to be included
422            else
423            {
424                // no prefix is the same as a +prefix
425                if ($word[0] == '+')
426                {
427                    $word = substr($word, 1);
428                }
429
430                // a group of words of which at least one word should be in every resulting post
431                if (isset($word[0]) && $word[0] == '(')
432                {
433                    $word = array_unique(explode('|', substr($word, 1, -1)));
434                }
435                $ignore_no_id = false;
436                $mode = 'must_contain';
437            }
438
439            if (empty($word))
440            {
441                continue;
442            }
443
444            // if this is an array of words then retrieve an id for each
445            if (is_array($word))
446            {
447                $non_common_words = array();
448                $id_words = array();
449                foreach ($word as $i => $word_part)
450                {
451                    if (strpos($word_part, '*') !== false)
452                    {
453                        $len = utf8_strlen(str_replace('*', '', $word_part));
454                        if ($len >= $this->word_length['min'] && $len <= $this->word_length['max'])
455                        {
456                            $id_words[] = '\'' . $this->db->sql_escape(str_replace('*', '%', $word_part)) . '\'';
457                            $non_common_words[] = $word_part;
458                        }
459                        else
460                        {
461                            $this->common_words[] = $word_part;
462                        }
463                    }
464                    else if (isset($words[$word_part]))
465                    {
466                        $id_words[] = $words[$word_part];
467                        $non_common_words[] = $word_part;
468                    }
469                    else
470                    {
471                        $len = utf8_strlen($word_part);
472                        if ($len < $this->word_length['min'] || $len > $this->word_length['max'])
473                        {
474                            $this->common_words[] = $word_part;
475                        }
476                    }
477                }
478                if (count($id_words))
479                {
480                    sort($id_words);
481                    if (count($id_words) > 1)
482                    {
483                        $this->{$mode . '_ids'}[] = $id_words;
484                    }
485                    else
486                    {
487                        $mode = ($mode == 'must_exclude_one') ? 'must_not_contain' : $mode;
488                        $this->{$mode . '_ids'}[] = $id_words[0];
489                    }
490                }
491                // throw an error if we shall not ignore unexistant words
492                else if (!$ignore_no_id && count($non_common_words))
493                {
494                    trigger_error(sprintf($this->language->lang('WORDS_IN_NO_POST'), implode($this->language->lang('COMMA_SEPARATOR'), $non_common_words)));
495                }
496                unset($non_common_words);
497            }
498            // else we only need one id
499            else if (($wildcard = strpos($word, '*') !== false) || isset($words[$word]))
500            {
501                if ($wildcard)
502                {
503                    $len = utf8_strlen(str_replace('*', '', $word));
504                    if ($len >= $this->word_length['min'] && $len <= $this->word_length['max'])
505                    {
506                        $this->{$mode . '_ids'}[] = '\'' . $this->db->sql_escape(str_replace('*', '%', $word)) . '\'';
507                    }
508                    else
509                    {
510                        $this->common_words[] = $word;
511                    }
512                }
513                else
514                {
515                    $this->{$mode . '_ids'}[] = $words[$word];
516                }
517            }
518            else
519            {
520                if (!isset($common_ids[$word]))
521                {
522                    $len = utf8_strlen($word);
523                    if ($len < $this->word_length['min'] || $len > $this->word_length['max'])
524                    {
525                        $this->common_words[] = $word;
526                    }
527                }
528            }
529        }
530
531        // Return true if all words are not common words
532        if (count($exact_words) - count($this->common_words) > 0)
533        {
534            return true;
535        }
536        return false;
537    }
538
539    /**
540     * {@inheritdoc}
541     */
542    public function keyword_search(string $type, string $fields, string $terms, array $sort_by_sql, string $sort_key, string $sort_dir, string $sort_days, array $ex_fid_ary, string $post_visibility, int $topic_id, array $author_ary, string $author_name, array &$id_ary, int &$start, int $per_page)
543    {
544        // No keywords? No posts.
545        if (empty($this->search_query))
546        {
547            return false;
548        }
549
550        // we can't search for negatives only
551        if (empty($this->must_contain_ids))
552        {
553            return false;
554        }
555
556        $must_contain_ids = $this->must_contain_ids;
557        $must_not_contain_ids = $this->must_not_contain_ids;
558        $must_exclude_one_ids = $this->must_exclude_one_ids;
559
560        sort($must_contain_ids);
561        sort($must_not_contain_ids);
562        sort($must_exclude_one_ids);
563
564        // generate a search_key from all the options to identify the results
565        $search_key_array = array(
566            serialize($must_contain_ids),
567            serialize($must_not_contain_ids),
568            serialize($must_exclude_one_ids),
569            $type,
570            $fields,
571            $terms,
572            $sort_days,
573            $sort_key,
574            $topic_id,
575            implode(',', $ex_fid_ary),
576            $post_visibility,
577            implode(',', $author_ary),
578            $author_name,
579        );
580
581        /**
582        * Allow changing the search_key for cached results
583        *
584        * @event core.search_native_by_keyword_modify_search_key
585        * @var    array    search_key_array    Array with search parameters to generate the search_key
586        * @var    array    must_contain_ids    Array with post ids of posts containing words that are to be included
587        * @var    array    must_not_contain_ids    Array with post ids of posts containing words that should not be included
588        * @var    array    must_exclude_one_ids    Array with post ids of posts containing at least one word that needs to be excluded
589        * @var    string    type                Searching type ('posts', 'topics')
590        * @var    string    fields                Searching fields ('titleonly', 'msgonly', 'firstpost', 'all')
591        * @var    string    terms                Searching terms ('all', 'any')
592        * @var    int        sort_days            Time, in days, of the oldest possible post to list
593        * @var    string    sort_key            The sort type used from the possible sort types
594        * @var    int        topic_id            Limit the search to this topic_id only
595        * @var    array    ex_fid_ary            Which forums not to search on
596        * @var    string    post_visibility        Post visibility data
597        * @var    array    author_ary            Array of user_id containing the users to filter the results to
598        * @since 3.1.7-RC1
599        */
600        $vars = array(
601            'search_key_array',
602            'must_contain_ids',
603            'must_not_contain_ids',
604            'must_exclude_one_ids',
605            'type',
606            'fields',
607            'terms',
608            'sort_days',
609            'sort_key',
610            'topic_id',
611            'ex_fid_ary',
612            'post_visibility',
613            'author_ary',
614        );
615        extract($this->phpbb_dispatcher->trigger_event('core.search_native_by_keyword_modify_search_key', compact($vars)));
616
617        $search_key = md5(implode('#', $search_key_array));
618
619        // try reading the results from cache
620        $total_results = 0;
621        if ($this->obtain_ids($search_key, $total_results, $id_ary, $start, $per_page, $sort_dir) == self::SEARCH_RESULT_IN_CACHE)
622        {
623            return $total_results;
624        }
625
626        $id_ary = array();
627
628        $sql_where = array();
629        $m_num = 0;
630        $w_num = 0;
631
632        $sql_array = array(
633            'SELECT'    => ($type == 'posts') ? 'DISTINCT p.post_id' : 'DISTINCT p.topic_id',
634            'FROM'        => array(
635                $this->search_wordmatch_table    => array(),
636                $this->search_wordlist_table    => array(),
637            ),
638            'LEFT_JOIN' => array(array(
639                'FROM'    => array(POSTS_TABLE => 'p'),
640                'ON'    => 'm0.post_id = p.post_id',
641            )),
642        );
643
644        $title_match = '';
645        $left_join_topics = false;
646        $group_by = true;
647        // Build some display specific sql strings
648        switch ($fields)
649        {
650            case 'titleonly':
651                $title_match = 'title_match = 1';
652                $group_by = false;
653            // no break
654            case 'firstpost':
655                $left_join_topics = true;
656                $sql_where[] = 'p.post_id = t.topic_first_post_id';
657            break;
658
659            case 'msgonly':
660                $title_match = 'title_match = 0';
661                $group_by = false;
662            break;
663        }
664
665        if ($type == 'topics')
666        {
667            $left_join_topics = true;
668            $group_by = true;
669        }
670
671        /**
672        * @todo Add a query optimizer (handle stuff like "+(4|3) +4")
673        */
674
675        foreach ($this->must_contain_ids as $subquery)
676        {
677            if (is_array($subquery))
678            {
679                $group_by = true;
680
681                $word_id_sql = array();
682                $word_ids = array();
683                foreach ($subquery as $id)
684                {
685                    if (is_string($id))
686                    {
687                        $sql_array['LEFT_JOIN'][] = array(
688                            'FROM'    => array($this->search_wordlist_table => 'w' . $w_num),
689                            'ON'    => "w$w_num.word_text LIKE $id"
690                        );
691                        $word_ids[] = "w$w_num.word_id";
692
693                        $w_num++;
694                    }
695                    else
696                    {
697                        $word_ids[] = $id;
698                    }
699                }
700
701                $sql_where[] = $this->db->sql_in_set("m$m_num.word_id", $word_ids);
702
703                unset($word_id_sql);
704                unset($word_ids);
705            }
706            else if (is_string($subquery))
707            {
708                $sql_array['FROM'][$this->search_wordlist_table][] = 'w' . $w_num;
709
710                $sql_where[] = "w$w_num.word_text LIKE $subquery";
711                $sql_where[] = "m$m_num.word_id = w$w_num.word_id";
712
713                $group_by = true;
714                $w_num++;
715            }
716            else
717            {
718                $sql_where[] = "m$m_num.word_id = $subquery";
719            }
720
721            $sql_array['FROM'][$this->search_wordmatch_table][] = 'm' . $m_num;
722
723            if ($title_match)
724            {
725                $sql_where[] = "m$m_num.$title_match";
726            }
727
728            if ($m_num != 0)
729            {
730                $sql_where[] = "m$m_num.post_id = m0.post_id";
731            }
732            $m_num++;
733        }
734
735        foreach ($this->must_not_contain_ids as $key => $subquery)
736        {
737            if (is_string($subquery))
738            {
739                $sql_array['LEFT_JOIN'][] = array(
740                    'FROM'    => array($this->search_wordlist_table => 'w' . $w_num),
741                    'ON'    => "w$w_num.word_text LIKE $subquery"
742                );
743
744                $this->must_not_contain_ids[$key] = "w$w_num.word_id";
745
746                $group_by = true;
747                $w_num++;
748            }
749        }
750
751        if (count($this->must_not_contain_ids))
752        {
753            $sql_array['LEFT_JOIN'][] = array(
754                'FROM'    => array($this->search_wordmatch_table => 'm' . $m_num),
755                'ON'    => $this->db->sql_in_set("m$m_num.word_id", $this->must_not_contain_ids) . (($title_match) ? " AND m$m_num.$title_match" : '') . " AND m$m_num.post_id = m0.post_id"
756            );
757
758            $sql_where[] = "m$m_num.word_id IS NULL";
759            $m_num++;
760        }
761
762        foreach ($this->must_exclude_one_ids as $ids)
763        {
764            $is_null_joins = array();
765            foreach ($ids as $id)
766            {
767                if (is_string($id))
768                {
769                    $sql_array['LEFT_JOIN'][] = array(
770                        'FROM'    => array($this->search_wordlist_table => 'w' . $w_num),
771                        'ON'    => "w$w_num.word_text LIKE $id"
772                    );
773                    $id = "w$w_num.word_id";
774
775                    $group_by = true;
776                    $w_num++;
777                }
778
779                $sql_array['LEFT_JOIN'][] = array(
780                    'FROM'    => array($this->search_wordmatch_table => 'm' . $m_num),
781                    'ON'    => "m$m_num.word_id = $id AND m$m_num.post_id = m0.post_id" . (($title_match) ? " AND m$m_num.$title_match" : '')
782                );
783                $is_null_joins[] = "m$m_num.word_id IS NULL";
784
785                $m_num++;
786            }
787            $sql_where[] = '(' . implode(' OR ', $is_null_joins) . ')';
788        }
789
790        $sql_where[] = $post_visibility;
791
792        $search_query = $this->search_query;
793        $must_exclude_one_ids = $this->must_exclude_one_ids;
794        $must_not_contain_ids = $this->must_not_contain_ids;
795        $must_contain_ids = $this->must_contain_ids;
796
797        $sql_sort_table = $sql_sort_join = $sql_match = $sql_match_where = $sql_sort = '';
798
799        /**
800        * Allow changing the query used for counting for posts using fulltext_native
801        *
802        * @event core.search_native_keywords_count_query_before
803        * @var    string    search_query            The parsed keywords used for this search
804        * @var    array    must_not_contain_ids    Ids that cannot be taken into account for the results
805        * @var    array    must_exclude_one_ids    Ids that cannot be on the results
806        * @var    array    must_contain_ids        Ids that must be on the results
807        * @var    int        total_results            The previous result count for the format of the query
808        *                                        Set to 0 to force a re-count
809        * @var    array    sql_array                The data on how to search in the DB at this point
810        * @var    bool    left_join_topics        Whether or not TOPICS_TABLE should be CROSS JOIN'ED
811        * @var    array    author_ary                Array of user_id containing the users to filter the results to
812        * @var    string    author_name                An extra username to search on (!empty(author_ary) must be true, to be relevant)
813        * @var    array    ex_fid_ary                Which forums not to search on
814        * @var    int        topic_id                Limit the search to this topic_id only
815        * @var    string    sql_sort_table            Extra tables to include in the SQL query.
816        *                                        Used in conjunction with sql_sort_join
817        * @var    string    sql_sort_join            SQL conditions to join all the tables used together.
818        *                                        Used in conjunction with sql_sort_table
819        * @var    int        sort_days                Time, in days, of the oldest possible post to list
820        * @var    string    sql_where                An array of the current WHERE clause conditions
821        * @var    string    sql_match                Which columns to do the search on
822        * @var    string    sql_match_where            Extra conditions to use to properly filter the matching process
823        * @var    bool    group_by                Whether or not the SQL query requires a GROUP BY for the elements in the SELECT clause
824        * @var    string    sort_by_sql                The possible predefined sort types
825        * @var    string    sort_key                The sort type used from the possible sort types
826        * @var    string    sort_dir                "a" for ASC or "d" dor DESC for the sort order used
827        * @var    string    sql_sort                The result SQL when processing sort_by_sql + sort_key + sort_dir
828        * @var    int        start                    How many posts to skip in the search results (used for pagination)
829        * @since 3.1.5-RC1
830        */
831        $vars = array(
832            'search_query',
833            'must_not_contain_ids',
834            'must_exclude_one_ids',
835            'must_contain_ids',
836            'total_results',
837            'sql_array',
838            'left_join_topics',
839            'author_ary',
840            'author_name',
841            'ex_fid_ary',
842            'topic_id',
843            'sql_sort_table',
844            'sql_sort_join',
845            'sort_days',
846            'sql_where',
847            'sql_match',
848            'sql_match_where',
849            'group_by',
850            'sort_by_sql',
851            'sort_key',
852            'sort_dir',
853            'sql_sort',
854            'start',
855        );
856        extract($this->phpbb_dispatcher->trigger_event('core.search_native_keywords_count_query_before', compact($vars)));
857
858        if ($topic_id)
859        {
860            $sql_where[] = 'p.topic_id = ' . $topic_id;
861        }
862
863        if (count($author_ary))
864        {
865            if ($author_name)
866            {
867                // first one matches post of registered users, second one guests and deleted users
868                $sql_author = '(' . $this->db->sql_in_set('p.poster_id', array_diff($author_ary, array(ANONYMOUS)), false, true) . ' OR p.post_username ' . $author_name . ')';
869            }
870            else
871            {
872                $sql_author = $this->db->sql_in_set('p.poster_id', $author_ary);
873            }
874            $sql_where[] = $sql_author;
875        }
876
877        if (count($ex_fid_ary))
878        {
879            $sql_where[] = $this->db->sql_in_set('p.forum_id', $ex_fid_ary, true);
880        }
881
882        if ($sort_days)
883        {
884            $sql_where[] = 'p.post_time >= ' . (time() - ($sort_days * 86400));
885        }
886
887        $sql_array['WHERE'] = implode(' AND ', $sql_where);
888
889        $is_mysql = false;
890        // if the total result count is not cached yet, retrieve it from the db
891        if (!$total_results)
892        {
893            $sql = '';
894            $sql_array_count = $sql_array;
895
896            if ($left_join_topics)
897            {
898                $sql_array_count['LEFT_JOIN'][] = array(
899                    'FROM'    => array(TOPICS_TABLE => 't'),
900                    'ON'    => 'p.topic_id = t.topic_id'
901                );
902            }
903
904            switch ($this->db->get_sql_layer())
905            {
906                case 'mysqli':
907                    $is_mysql = true;
908
909                break;
910
911                case 'sqlite3':
912                    $sql_array_count['SELECT'] = ($type == 'posts') ? 'DISTINCT p.post_id' : 'DISTINCT p.topic_id';
913                    $sql = 'SELECT COUNT(' . (($type == 'posts') ? 'post_id' : 'topic_id') . ') as total_results
914                            FROM (' . $this->db->sql_build_query('SELECT', $sql_array_count) . ')';
915
916                // no break
917
918                default:
919                    $sql_array_count['SELECT'] = ($type == 'posts') ? 'COUNT(DISTINCT p.post_id) AS total_results' : 'COUNT(DISTINCT p.topic_id) AS total_results';
920                    $sql = (!$sql) ? $this->db->sql_build_query('SELECT', $sql_array_count) : $sql;
921
922                    $result = $this->db->sql_query($sql);
923                    $total_results = (int) $this->db->sql_fetchfield('total_results');
924                    $this->db->sql_freeresult($result);
925
926                    if (!$total_results)
927                    {
928                        return false;
929                    }
930                break;
931            }
932
933            unset($sql_array_count, $sql);
934        }
935
936        // Build sql strings for sorting
937        $sql_sort = $sort_by_sql[$sort_key] . (($sort_dir == 'a') ? ' ASC' : ' DESC');
938
939        switch ($sql_sort[0])
940        {
941            case 'u':
942                $sql_array['FROM'][USERS_TABLE] = 'u';
943                $sql_where[] = 'u.user_id = p.poster_id ';
944            break;
945
946            case 't':
947                $left_join_topics = true;
948            break;
949
950            case 'f':
951                $sql_array['FROM'][FORUMS_TABLE] = 'f';
952                $sql_where[] = 'f.forum_id = p.forum_id';
953            break;
954        }
955
956        if ($left_join_topics)
957        {
958            $sql_array['LEFT_JOIN'][] = array(
959                'FROM'    => array(TOPICS_TABLE => 't'),
960                'ON'    => 'p.topic_id = t.topic_id'
961            );
962        }
963
964        $sql_array['WHERE'] = implode(' AND ', $sql_where);
965        $sql_array['GROUP_BY'] = ($group_by) ? (($type == 'posts') ? 'p.post_id' : 'p.topic_id') . ', ' . $sort_by_sql[$sort_key] : '';
966        $sql_array['ORDER_BY'] = $sql_sort;
967        $sql_array['SELECT'] .= $sort_by_sql[$sort_key] ? "{$sort_by_sql[$sort_key]}" : '';
968
969        unset($sql_where, $sql_sort, $group_by);
970
971        $sql = $this->db->sql_build_query('SELECT', $sql_array);
972        $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
973
974        while ($row = $this->db->sql_fetchrow($result))
975        {
976            $id_ary[] = (int) $row[(($type == 'posts') ? 'post_id' : 'topic_id')];
977        }
978        $this->db->sql_freeresult($result);
979
980        // If using mysql and the total result count is not calculated yet, get it from the db
981        if (!$total_results && $is_mysql)
982        {
983            $sql_count = str_replace("SELECT {$sql_array['SELECT']}", "SELECT COUNT({$sql_array['SELECT']}) as total_results", $sql);
984            $result = $this->db->sql_query($sql_count);
985            $total_results = $sql_array['GROUP_BY'] ? count($this->db->sql_fetchrowset($result)) : $this->db->sql_fetchfield('total_results');
986            $this->db->sql_freeresult($result);
987
988            if (!$total_results)
989            {
990                return false;
991            }
992        }
993
994        if ($start >= $total_results)
995        {
996            $start = floor(($total_results - 1) / $per_page) * $per_page;
997
998            $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
999
1000            while ($row = $this->db->sql_fetchrow($result))
1001            {
1002                $id_ary[] = (int) $row[(($type == 'posts') ? 'post_id' : 'topic_id')];
1003            }
1004            $this->db->sql_freeresult($result);
1005        }
1006
1007        $id_ary = array_unique($id_ary);
1008
1009        // store the ids, from start on then delete anything that isn't on the current page because we only need ids for one page
1010        $this->save_ids($search_key, $this->search_query, $author_ary, $total_results, $id_ary, $start, $sort_dir);
1011        $id_ary = array_slice($id_ary, 0, (int) $per_page);
1012
1013        return $total_results;
1014    }
1015
1016    /**
1017     * {@inheritdoc}
1018     */
1019    public function author_search(string $type, bool $firstpost_only, array $sort_by_sql, string $sort_key, string $sort_dir, string $sort_days, array $ex_fid_ary, string $post_visibility, int $topic_id, array $author_ary, string $author_name, array &$id_ary, int &$start, int $per_page)
1020    {
1021        // No author? No posts
1022        if (!count($author_ary))
1023        {
1024            return 0;
1025        }
1026
1027        // generate a search_key from all the options to identify the results
1028        $search_key_array = array(
1029            '',
1030            $type,
1031            ($firstpost_only) ? 'firstpost' : '',
1032            '',
1033            '',
1034            $sort_days,
1035            $sort_key,
1036            $topic_id,
1037            implode(',', $ex_fid_ary),
1038            $post_visibility,
1039            implode(',', $author_ary),
1040            $author_name,
1041        );
1042
1043        /**
1044        * Allow changing the search_key for cached results
1045        *
1046        * @event core.search_native_by_author_modify_search_key
1047        * @var    array    search_key_array    Array with search parameters to generate the search_key
1048        * @var    string    type                Searching type ('posts', 'topics')
1049        * @var    boolean    firstpost_only        Flag indicating if only topic starting posts are considered
1050        * @var    int        sort_days            Time, in days, of the oldest possible post to list
1051        * @var    string    sort_key            The sort type used from the possible sort types
1052        * @var    int        topic_id            Limit the search to this topic_id only
1053        * @var    array    ex_fid_ary            Which forums not to search on
1054        * @var    string    post_visibility        Post visibility data
1055        * @var    array    author_ary            Array of user_id containing the users to filter the results to
1056        * @var    string    author_name            The username to search on
1057        * @since 3.1.7-RC1
1058        */
1059        $vars = array(
1060            'search_key_array',
1061            'type',
1062            'firstpost_only',
1063            'sort_days',
1064            'sort_key',
1065            'topic_id',
1066            'ex_fid_ary',
1067            'post_visibility',
1068            'author_ary',
1069            'author_name',
1070        );
1071        extract($this->phpbb_dispatcher->trigger_event('core.search_native_by_author_modify_search_key', compact($vars)));
1072
1073        $search_key = md5(implode('#', $search_key_array));
1074
1075        // try reading the results from cache
1076        $total_results = 0;
1077        if ($this->obtain_ids($search_key, $total_results, $id_ary, $start, $per_page, $sort_dir) == self::SEARCH_RESULT_IN_CACHE)
1078        {
1079            return $total_results;
1080        }
1081
1082        $id_ary = array();
1083
1084        // Create some display specific sql strings
1085        if ($author_name)
1086        {
1087            // first one matches post of registered users, second one guests and deleted users
1088            $sql_author = '(' . $this->db->sql_in_set('p.poster_id', array_diff($author_ary, array(ANONYMOUS)), false, true) . ' OR p.post_username ' . $author_name . ')';
1089        }
1090        else
1091        {
1092            $sql_author = $this->db->sql_in_set('p.poster_id', $author_ary);
1093        }
1094        $sql_fora        = (count($ex_fid_ary)) ? ' AND ' . $this->db->sql_in_set('p.forum_id', $ex_fid_ary, true) : '';
1095        $sql_time        = ($sort_days) ? ' AND p.post_time >= ' . (time() - ($sort_days * 86400)) : '';
1096        $sql_topic_id    = ($topic_id) ? ' AND p.topic_id = ' . (int) $topic_id : '';
1097        $sql_firstpost = ($firstpost_only) ? ' AND p.post_id = t.topic_first_post_id' : '';
1098        $post_visibility = ($post_visibility) ? ' AND ' . $post_visibility : '';
1099
1100        // Build sql strings for sorting
1101        $sql_sort = $sort_by_sql[$sort_key] . (($sort_dir == 'a') ? ' ASC' : ' DESC');
1102        $sql_sort_table = $sql_sort_join = '';
1103        switch ($sql_sort[0])
1104        {
1105            case 'u':
1106                $sql_sort_table    = USERS_TABLE . ' u, ';
1107                $sql_sort_join    = ' AND u.user_id = p.poster_id ';
1108            break;
1109
1110            case 't':
1111                $sql_sort_table    = ($type == 'posts' && !$firstpost_only) ? TOPICS_TABLE . ' t, ' : '';
1112                $sql_sort_join    = ($type == 'posts' && !$firstpost_only) ? ' AND t.topic_id = p.topic_id ' : '';
1113            break;
1114
1115            case 'f':
1116                $sql_sort_table    = FORUMS_TABLE . ' f, ';
1117                $sql_sort_join    = ' AND f.forum_id = p.forum_id ';
1118            break;
1119        }
1120
1121        $select = ($type == 'posts') ? 'p.post_id' : 't.topic_id';
1122        $select .= $sort_by_sql[$sort_key] ? "{$sort_by_sql[$sort_key]}" : '';
1123        $is_mysql = false;
1124
1125        /**
1126        * Allow changing the query used to search for posts by author in fulltext_native
1127        *
1128        * @event core.search_native_author_count_query_before
1129        * @var    int        total_results        The previous result count for the format of the query.
1130        *                                    Set to 0 to force a re-count
1131        * @var    string    type                The type of search being made
1132        * @var    string    select                SQL SELECT clause for what to get
1133        * @var    string    sql_sort_table        CROSS JOIN'ed table to allow doing the sort chosen
1134        * @var    string    sql_sort_join        Condition to define how to join the CROSS JOIN'ed table specifyed in sql_sort_table
1135        * @var    array    sql_author            SQL WHERE condition for the post author ids
1136        * @var    int        topic_id            Limit the search to this topic_id only
1137        * @var    string    sort_by_sql            The possible predefined sort types
1138        * @var    string    sort_key            The sort type used from the possible sort types
1139        * @var    string    sort_dir            "a" for ASC or "d" dor DESC for the sort order used
1140        * @var    string    sql_sort            The result SQL when processing sort_by_sql + sort_key + sort_dir
1141        * @var    string    sort_days            Time, in days, that the oldest post showing can have
1142        * @var    string    sql_time            The SQL to search on the time specifyed by sort_days
1143        * @var    bool    firstpost_only        Wether or not to search only on the first post of the topics
1144        * @var    string    sql_firstpost        The SQL used in the WHERE claused to filter by firstpost.
1145        * @var    array    ex_fid_ary            Forum ids that must not be searched on
1146        * @var    array    sql_fora            SQL query for ex_fid_ary
1147        * @var    int        start                How many posts to skip in the search results (used for pagination)
1148        * @since 3.1.5-RC1
1149        */
1150        $vars = array(
1151            'total_results',
1152            'type',
1153            'select',
1154            'sql_sort_table',
1155            'sql_sort_join',
1156            'sql_author',
1157            'topic_id',
1158            'sort_by_sql',
1159            'sort_key',
1160            'sort_dir',
1161            'sql_sort',
1162            'sort_days',
1163            'sql_time',
1164            'firstpost_only',
1165            'sql_firstpost',
1166            'ex_fid_ary',
1167            'sql_fora',
1168            'start',
1169        );
1170        extract($this->phpbb_dispatcher->trigger_event('core.search_native_author_count_query_before', compact($vars)));
1171
1172        // If the cache was completely empty count the results
1173        if (!$total_results)
1174        {
1175            switch ($this->db->get_sql_layer())
1176            {
1177                case 'mysqli':
1178                    $is_mysql = true;
1179                break;
1180
1181                default:
1182                    if ($type == 'posts')
1183                    {
1184                        $sql = 'SELECT COUNT(p.post_id) as total_results
1185                            FROM ' . POSTS_TABLE . ' p' . (($firstpost_only) ? ', ' . TOPICS_TABLE . ' t ' : ' ') . "
1186                            WHERE $sql_author
1187                                $sql_topic_id
1188                                $sql_firstpost
1189                                $post_visibility
1190                                $sql_fora
1191                                $sql_time";
1192                    }
1193                    else
1194                    {
1195                        if ($this->db->get_sql_layer() == 'sqlite3')
1196                        {
1197                            $sql = 'SELECT COUNT(topic_id) as total_results
1198                                FROM (SELECT DISTINCT t.topic_id';
1199                        }
1200                        else
1201                        {
1202                            $sql = 'SELECT COUNT(DISTINCT t.topic_id) as total_results';
1203                        }
1204
1205                        $sql .= ' FROM ' . TOPICS_TABLE . ' t, ' . POSTS_TABLE . " p
1206                            WHERE $sql_author
1207                                $sql_topic_id
1208                                $sql_firstpost
1209                                $post_visibility
1210                                $sql_fora
1211                                AND t.topic_id = p.topic_id
1212                                $sql_time" . ($this->db->get_sql_layer() == 'sqlite3' ? ')' : '');
1213                    }
1214                    $result = $this->db->sql_query($sql);
1215
1216                    $total_results = (int) $this->db->sql_fetchfield('total_results');
1217                    $this->db->sql_freeresult($result);
1218
1219                    if (!$total_results)
1220                    {
1221                        return false;
1222                    }
1223                break;
1224            }
1225        }
1226
1227        // Build the query for really selecting the post_ids
1228        if ($type == 'posts')
1229        {
1230            // For sorting by non-unique columns, add unique sort key to avoid duplicated rows in results
1231            $sql_sort .= ', p.post_id' . (($sort_dir == 'a') ? ' ASC' : ' DESC');
1232            $sql = "SELECT $select
1233                FROM " . $sql_sort_table . POSTS_TABLE . ' p' . (($firstpost_only) ? ', ' . TOPICS_TABLE . ' t' : '') . "
1234                WHERE $sql_author
1235                    $sql_topic_id
1236                    $sql_firstpost
1237                    $post_visibility
1238                    $sql_fora
1239                    $sql_sort_join
1240                    $sql_time
1241                ORDER BY $sql_sort";
1242            $field = 'post_id';
1243        }
1244        else
1245        {
1246            $sql = "SELECT $select
1247                FROM " . $sql_sort_table . TOPICS_TABLE . ' t, ' . POSTS_TABLE . " p
1248                WHERE $sql_author
1249                    $sql_topic_id
1250                    $sql_firstpost
1251                    $post_visibility
1252                    $sql_fora
1253                    AND t.topic_id = p.topic_id
1254                    $sql_sort_join
1255                    $sql_time
1256                GROUP BY t.topic_id, " . $sort_by_sql[$sort_key] . '
1257                ORDER BY ' . $sql_sort;
1258            $field = 'topic_id';
1259        }
1260
1261        // Only read one block of posts from the db and then cache it
1262        $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
1263
1264        while ($row = $this->db->sql_fetchrow($result))
1265        {
1266            $id_ary[] = (int) $row[$field];
1267        }
1268        $this->db->sql_freeresult($result);
1269
1270        if (!$total_results && $is_mysql)
1271        {
1272            $sql_count = str_replace("SELECT $select", "SELECT COUNT(*) as total_results", $sql);
1273            $result = $this->db->sql_query($sql_count);
1274            $total_results = ($type == 'posts') ? (int) $this->db->sql_fetchfield('total_results') : count($this->db->sql_fetchrowset($result));
1275            $this->db->sql_freeresult($result);
1276
1277            if (!$total_results)
1278            {
1279                return false;
1280            }
1281        }
1282
1283        if ($start >= $total_results)
1284        {
1285            $start = floor(($total_results - 1) / $per_page) * $per_page;
1286
1287            $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
1288
1289            while ($row = $this->db->sql_fetchrow($result))
1290            {
1291                $id_ary[] = (int) $row[$field];
1292            }
1293            $this->db->sql_freeresult($result);
1294        }
1295
1296        $id_ary = array_unique($id_ary);
1297
1298        if (count($id_ary))
1299        {
1300            $this->save_ids($search_key, '', $author_ary, $total_results, $id_ary, $start, $sort_dir);
1301            $id_ary = array_slice($id_ary, 0, $per_page);
1302
1303            return $total_results;
1304        }
1305        return false;
1306    }
1307
1308    /**
1309     * {@inheritdoc}
1310     */
1311    public function supports_phrase_search(): bool
1312    {
1313        return false;
1314    }
1315
1316    /**
1317     * {@inheritdoc}
1318    */
1319    public function index(string $mode, int $post_id, string &$message, string &$subject, int $poster_id, int $forum_id)
1320    {
1321        if (!$this->config['fulltext_native_load_upd'])
1322        {
1323            /**
1324            * The search indexer is disabled, return
1325            */
1326            return;
1327        }
1328
1329        // Split old and new post/subject to obtain array of 'words'
1330        $split_text = $this->split_message($message);
1331        $split_title = $this->split_message($subject);
1332
1333        $cur_words = array('post' => array(), 'title' => array());
1334
1335        $words = array();
1336        if ($mode == 'edit')
1337        {
1338            $words['add']['post'] = array();
1339            $words['add']['title'] = array();
1340            $words['del']['post'] = array();
1341            $words['del']['title'] = array();
1342
1343            $sql = 'SELECT w.word_id, w.word_text, m.title_match
1344                FROM ' . $this->search_wordlist_table . ' w, ' . $this->search_wordmatch_table . " m
1345                WHERE m.post_id = $post_id
1346                    AND w.word_id = m.word_id";
1347            $result = $this->db->sql_query($sql);
1348
1349            while ($row = $this->db->sql_fetchrow($result))
1350            {
1351                $which = ($row['title_match']) ? 'title' : 'post';
1352                $cur_words[$which][$row['word_text']] = $row['word_id'];
1353            }
1354            $this->db->sql_freeresult($result);
1355
1356            $words['add']['post'] = array_diff($split_text, array_keys($cur_words['post']));
1357            $words['add']['title'] = array_diff($split_title, array_keys($cur_words['title']));
1358            $words['del']['post'] = array_diff(array_keys($cur_words['post']), $split_text);
1359            $words['del']['title'] = array_diff(array_keys($cur_words['title']), $split_title);
1360        }
1361        else
1362        {
1363            $words['add']['post'] = $split_text;
1364            $words['add']['title'] = $split_title;
1365            $words['del']['post'] = array();
1366            $words['del']['title'] = array();
1367        }
1368
1369        /**
1370        * Event to modify method arguments and words before the native search index is updated
1371        *
1372        * @event core.search_native_index_before
1373        * @var string    mode                Contains the post mode: edit, post, reply, quote
1374        * @var int        post_id                The id of the post which is modified/created
1375        * @var string    message                New or updated post content
1376        * @var string    subject                New or updated post subject
1377        * @var int        poster_id            Post author's user id
1378        * @var int        forum_id            The id of the forum in which the post is located
1379        * @var array    words                Grouped lists of words added to or remove from the index
1380        * @var array    split_text            Array of words from the message
1381        * @var array    split_title            Array of words from the title
1382        * @var array    cur_words            Array of words currently in the index for comparing to new words
1383        *                                     when mode is edit. Empty for other modes.
1384        * @since 3.2.3-RC1
1385        */
1386        $vars = array(
1387            'mode',
1388            'post_id',
1389            'message',
1390            'subject',
1391            'poster_id',
1392            'forum_id',
1393            'words',
1394            'split_text',
1395            'split_title',
1396            'cur_words',
1397        );
1398        extract($this->phpbb_dispatcher->trigger_event('core.search_native_index_before', compact($vars)));
1399
1400        unset($split_text);
1401        unset($split_title);
1402
1403        // Get unique words from the above arrays
1404        $unique_add_words = array_unique(array_merge($words['add']['post'], $words['add']['title']));
1405
1406        // We now have unique arrays of all words to be added and removed and
1407        // individual arrays of added and removed words for text and title. What
1408        // we need to do now is add the new words (if they don't already exist)
1409        // and then add (or remove) matches between the words and this post
1410        if (count($unique_add_words))
1411        {
1412            $sql = 'SELECT word_id, word_text
1413                FROM ' . $this->search_wordlist_table . '
1414                WHERE ' . $this->db->sql_in_set('word_text', $unique_add_words);
1415            $result = $this->db->sql_query($sql);
1416
1417            $word_ids = array();
1418            while ($row = $this->db->sql_fetchrow($result))
1419            {
1420                $word_ids[$row['word_text']] = $row['word_id'];
1421            }
1422            $this->db->sql_freeresult($result);
1423            $new_words = array_diff($unique_add_words, array_keys($word_ids));
1424
1425            $this->db->sql_transaction('begin');
1426            if (count($new_words))
1427            {
1428                $sql_ary = array();
1429
1430                foreach ($new_words as $word)
1431                {
1432                    $sql_ary[] = array('word_text' => (string) $word, 'word_count' => 0);
1433                }
1434                $this->db->sql_return_on_error(true);
1435                $this->db->sql_multi_insert($this->search_wordlist_table, $sql_ary);
1436                $this->db->sql_return_on_error(false);
1437            }
1438            unset($new_words, $sql_ary);
1439        }
1440        else
1441        {
1442            $this->db->sql_transaction('begin');
1443        }
1444
1445        // now update the search match table, remove links to removed words and add links to new words
1446        foreach ($words['del'] as $word_in => $word_ary)
1447        {
1448            $title_match = ($word_in == 'title') ? 1 : 0;
1449
1450            if (count($word_ary))
1451            {
1452                $sql_in = array();
1453                foreach ($word_ary as $word)
1454                {
1455                    $sql_in[] = $cur_words[$word_in][$word];
1456                }
1457
1458                $sql = 'DELETE FROM ' . $this->search_wordmatch_table . '
1459                    WHERE ' . $this->db->sql_in_set('word_id', $sql_in) . '
1460                        AND post_id = ' . intval($post_id) . "
1461                        AND title_match = $title_match";
1462                $this->db->sql_query($sql);
1463
1464                $sql = 'UPDATE ' . $this->search_wordlist_table . '
1465                    SET word_count = word_count - 1
1466                    WHERE ' . $this->db->sql_in_set('word_id', $sql_in) . '
1467                        AND word_count > 0';
1468                $this->db->sql_query($sql);
1469
1470                unset($sql_in);
1471            }
1472        }
1473
1474        $this->db->sql_return_on_error(true);
1475        foreach ($words['add'] as $word_in => $word_ary)
1476        {
1477            $title_match = ($word_in == 'title') ? 1 : 0;
1478
1479            if (count($word_ary))
1480            {
1481                $sql = 'INSERT INTO ' . $this->search_wordmatch_table . ' (post_id, word_id, title_match)
1482                    SELECT ' . (int) $post_id . ', word_id, ' . (int) $title_match . '
1483                    FROM ' . $this->search_wordlist_table . '
1484                    WHERE ' . $this->db->sql_in_set('word_text', $word_ary);
1485                $this->db->sql_query($sql);
1486
1487                $sql = 'UPDATE ' . $this->search_wordlist_table . '
1488                    SET word_count = word_count + 1
1489                    WHERE ' . $this->db->sql_in_set('word_text', $word_ary);
1490                $this->db->sql_query($sql);
1491            }
1492        }
1493        $this->db->sql_return_on_error(false);
1494
1495        $this->db->sql_transaction('commit');
1496
1497        // destroy cached search results containing any of the words removed or added
1498        $this->destroy_cache(array_unique(array_merge($words['add']['post'], $words['add']['title'], $words['del']['post'], $words['del']['title'])), array($poster_id));
1499
1500        unset($unique_add_words);
1501        unset($words);
1502        unset($cur_words);
1503    }
1504
1505    /**
1506     * {@inheritdoc}
1507     */
1508    public function index_remove(array $post_ids, array $author_ids, array $forum_ids): void
1509    {
1510        if (count($post_ids))
1511        {
1512            $sql = 'SELECT w.word_id, w.word_text, m.title_match
1513                FROM ' . $this->search_wordmatch_table . ' m, ' . $this->search_wordlist_table . ' w
1514                WHERE ' . $this->db->sql_in_set('m.post_id', $post_ids) . '
1515                    AND w.word_id = m.word_id';
1516            $result = $this->db->sql_query($sql);
1517
1518            $message_word_ids = $title_word_ids = $word_texts = array();
1519            while ($row = $this->db->sql_fetchrow($result))
1520            {
1521                if ($row['title_match'])
1522                {
1523                    $title_word_ids[] = $row['word_id'];
1524                }
1525                else
1526                {
1527                    $message_word_ids[] = $row['word_id'];
1528                }
1529                $word_texts[] = $row['word_text'];
1530            }
1531            $this->db->sql_freeresult($result);
1532
1533            if (count($title_word_ids))
1534            {
1535                $sql = 'UPDATE ' . $this->search_wordlist_table . '
1536                    SET word_count = word_count - 1
1537                    WHERE ' . $this->db->sql_in_set('word_id', $title_word_ids) . '
1538                        AND word_count > 0';
1539                $this->db->sql_query($sql);
1540            }
1541
1542            if (count($message_word_ids))
1543            {
1544                $sql = 'UPDATE ' . $this->search_wordlist_table . '
1545                    SET word_count = word_count - 1
1546                    WHERE ' . $this->db->sql_in_set('word_id', $message_word_ids) . '
1547                        AND word_count > 0';
1548                $this->db->sql_query($sql);
1549            }
1550
1551            unset($title_word_ids);
1552            unset($message_word_ids);
1553
1554            $sql = 'DELETE FROM ' . $this->search_wordmatch_table . '
1555                WHERE ' . $this->db->sql_in_set('post_id', $post_ids);
1556            $this->db->sql_query($sql);
1557        }
1558
1559        $this->destroy_cache(array_unique($word_texts), array_unique($author_ids));
1560    }
1561
1562    /**
1563     * {@inheritdoc}
1564     */
1565    public function tidy(): void
1566    {
1567        // Is the fulltext indexer disabled? If yes then we need not
1568        // carry on ... it's okay ... I know when I'm not wanted boo hoo
1569        if (!$this->config['fulltext_native_load_upd'])
1570        {
1571            $this->config->set('search_last_gc', time(), false);
1572            return;
1573        }
1574
1575        $destroy_cache_words = array();
1576
1577        // Remove common words
1578        if ($this->config['num_posts'] >= 100 && $this->config['fulltext_native_common_thres'])
1579        {
1580            $common_threshold = ((float) $this->config['fulltext_native_common_thres']) / 100.0;
1581            // First, get the IDs of common words
1582            $sql = 'SELECT word_id, word_text
1583                FROM ' . $this->search_wordlist_table . '
1584                WHERE word_count > ' . floor($this->config['num_posts'] * $common_threshold) . '
1585                    OR word_common = 1';
1586            $result = $this->db->sql_query($sql);
1587
1588            $sql_in = array();
1589            while ($row = $this->db->sql_fetchrow($result))
1590            {
1591                $sql_in[] = $row['word_id'];
1592                $destroy_cache_words[] = $row['word_text'];
1593            }
1594            $this->db->sql_freeresult($result);
1595
1596            if (count($sql_in))
1597            {
1598                // Flag the words
1599                $sql = 'UPDATE ' . $this->search_wordlist_table . '
1600                    SET word_common = 1
1601                    WHERE ' . $this->db->sql_in_set('word_id', $sql_in);
1602                $this->db->sql_query($sql);
1603
1604                // by setting search_last_gc to the new time here we make sure that if a user reloads because the
1605                // following query takes too long, he won't run into it again
1606                $this->config->set('search_last_gc', time(), false);
1607
1608                // Delete the matches
1609                $sql = 'DELETE FROM ' . $this->search_wordmatch_table . '
1610                    WHERE ' . $this->db->sql_in_set('word_id', $sql_in);
1611                $this->db->sql_query($sql);
1612            }
1613            unset($sql_in);
1614        }
1615
1616        if (count($destroy_cache_words))
1617        {
1618            // destroy cached search results containing any of the words that are now common or were removed
1619            $this->destroy_cache(array_unique($destroy_cache_words));
1620        }
1621
1622        $this->config->set('search_last_gc', time(), false);
1623    }
1624
1625    // create_index is inherited from base.php
1626
1627    /**
1628     * {@inheritdoc}
1629     */
1630    public function delete_index(int|null &$post_counter = null): array|null
1631    {
1632        $truncate_tables = [
1633            $this->search_wordlist_table,
1634            $this->search_wordmatch_table,
1635            $this->search_results_table,
1636        ];
1637
1638        $stats = $this->stats;
1639
1640        /**
1641        * Event to modify SQL queries before the native search index is deleted
1642        *
1643        * @event core.search_native_delete_index_before
1644        *
1645        * @var array    stats                Array with statistics of the current index (read only)
1646        * @var array truncate_tables        Array with tables that will be truncated
1647        *
1648        * @since 3.2.3-RC1
1649        * @changed 4.0.0-a1 Removed sql_queries, only add/remove tables to truncate to truncate_tables
1650        */
1651        $vars = array(
1652            'stats',
1653            'truncate_tables',
1654        );
1655        extract($this->phpbb_dispatcher->trigger_event('core.search_native_delete_index_before', compact($vars)));
1656
1657        foreach ($truncate_tables as $table)
1658        {
1659            $this->db_tools->sql_truncate_table($table);
1660        }
1661
1662        return null;
1663    }
1664
1665    /**
1666     * {@inheritdoc}
1667    */
1668    public function index_created(): bool
1669    {
1670        if (!count($this->stats))
1671        {
1672            $this->get_stats();
1673        }
1674
1675        return $this->stats['total_words'] && $this->stats['total_matches'];
1676    }
1677
1678    /**
1679     * {@inheritdoc}
1680     */
1681    public function index_stats()
1682    {
1683        if (!count($this->stats))
1684        {
1685            $this->get_stats();
1686        }
1687
1688        return array(
1689            $this->language->lang('TOTAL_WORDS')        => $this->stats['total_words'],
1690            $this->language->lang('TOTAL_MATCHES')    => $this->stats['total_matches']);
1691    }
1692
1693    /**
1694     * Computes the stats and store them in the $this->stats associative array
1695     */
1696    protected function get_stats()
1697    {
1698        $this->stats['total_words']        = $this->db->get_estimated_row_count($this->search_wordlist_table);
1699        $this->stats['total_matches']    = $this->db->get_estimated_row_count($this->search_wordmatch_table);
1700    }
1701
1702    /**
1703     * Split a text into words of a given length
1704     *
1705     * The text is converted to UTF-8, cleaned up, and split. Then, words that
1706     * conform to the defined length range are returned in an array.
1707     *
1708     * NOTE: duplicates are NOT removed from the return array
1709     *
1710     * @param    string    $text    Text to split, encoded in UTF-8
1711     * @return    array            Array of UTF-8 words
1712     */
1713    protected function split_message($text)
1714    {
1715        $match = $words = array();
1716
1717        /**
1718         * Taken from the original code
1719         */
1720        // Do not index code
1721        $match[] = '#\[code(?:=.*?)?(\:?[0-9a-z]{5,})\].*?\[\/code(\:?[0-9a-z]{5,})\]#is';
1722        // BBcode
1723        $match[] = '#\[\/?[a-z0-9\*\+\-]+(?:=.*?)?(?::[a-z])?(\:?[0-9a-z]{5,})\]#';
1724
1725        $min = $this->word_length['min'];
1726
1727        $isset_min = $min - 1;
1728
1729        /**
1730         * Clean up the string, remove HTML tags, remove BBCodes
1731         */
1732        $word = strtok($this->cleanup(preg_replace($match, ' ', strip_tags($text)), '-1'), ' ');
1733
1734        while (strlen($word))
1735        {
1736            if (strlen($word) > 255 || strlen($word) <= $isset_min)
1737            {
1738                /**
1739                 * Words longer than 255 bytes are ignored. This will have to be
1740                 * changed whenever we change the length of search_wordlist.word_text
1741                 *
1742                 * Words shorter than $isset_min bytes are ignored, too
1743                 */
1744                $word = strtok(' ');
1745                continue;
1746            }
1747
1748            $len = utf8_strlen($word);
1749
1750            /**
1751             * Test whether the word is too short to be indexed.
1752             *
1753             * Note that this limit does NOT apply to CJK and Hangul
1754             */
1755            if ($len < $min)
1756            {
1757                /**
1758                 * Note: this could be optimized. If the codepoint is lower than Hangul's range
1759                 * we know that it will also be lower than CJK ranges
1760                 */
1761                if ((strncmp($word, self::UTF8_HANGUL_FIRST, 3) < 0 || strncmp($word, self::UTF8_HANGUL_LAST, 3) > 0)
1762                    && (strncmp($word, self::UTF8_CJK_FIRST, 3) < 0 || strncmp($word, self::UTF8_CJK_LAST, 3) > 0)
1763                    && (strncmp($word, self::UTF8_CJK_B_FIRST, 4) < 0 || strncmp($word, self::UTF8_CJK_B_LAST, 4) > 0))
1764                {
1765                    $word = strtok(' ');
1766                    continue;
1767                }
1768            }
1769
1770            $words[] = $word;
1771            $word = strtok(' ');
1772        }
1773
1774        return $words;
1775    }
1776
1777    /**
1778    * Clean up a text to remove non-alphanumeric characters
1779    *
1780    * This method receives a UTF-8 string, normalizes and validates it, replaces all
1781    * non-alphanumeric characters with strings then returns the result.
1782    *
1783    * Any number of "allowed chars" can be passed as a UTF-8 string in NFC.
1784    *
1785    * @param    string    $text            Text to split, in UTF-8 (not normalized or sanitized)
1786    * @param    string    $allowed_chars    String of special chars to allow
1787    * @param    string    $encoding        Text encoding
1788    * @return    string                    Cleaned up text, only alphanumeric chars are left
1789    */
1790    protected function cleanup($text, $allowed_chars = null, $encoding = 'utf-8')
1791    {
1792        static $conv = array(), $conv_loaded = array();
1793        $allow = array();
1794
1795        // Convert the text to UTF-8
1796        $encoding = strtolower($encoding);
1797        if ($encoding != 'utf-8')
1798        {
1799            $text = utf8_recode($text, $encoding);
1800        }
1801
1802        $utf_len_mask = array(
1803            "\xC0"    =>    2,
1804            "\xD0"    =>    2,
1805            "\xE0"    =>    3,
1806            "\xF0"    =>    4
1807        );
1808
1809        /**
1810        * Replace HTML entities and NCRs
1811        */
1812        $text = html_entity_decode(utf8_decode_ncr($text), ENT_QUOTES);
1813
1814        /**
1815        * Normalize to NFC
1816        */
1817        $text = \Normalizer::normalize($text);
1818
1819        /**
1820        * The first thing we do is:
1821        *
1822        * - convert ASCII-7 letters to lowercase
1823        * - remove the ASCII-7 non-alpha characters
1824        * - remove the bytes that should not appear in a valid UTF-8 string: 0xC0,
1825        *   0xC1 and 0xF5-0xFF
1826        *
1827        * @todo in theory, the third one is already taken care of during normalization and those chars should have been replaced by Unicode replacement chars
1828        */
1829        $sb_match    = "ISTCPAMELRDOJBNHFGVWUQKYXZ\r\n\t!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\xC0\xC1\xF5\xF6\xF7\xF8\xF9\xFA\xFB\xFC\xFD\xFE\xFF";
1830        $sb_replace    = 'istcpamelrdojbnhfgvwuqkyxz                                                                              ';
1831
1832        /**
1833        * This is the list of legal ASCII chars, it is automatically extended
1834        * with ASCII chars from $allowed_chars
1835        */
1836        $legal_ascii = ' eaisntroludcpmghbfvq10xy2j9kw354867z';
1837
1838        /**
1839        * Prepare an array containing the extra chars to allow
1840        */
1841        if (isset($allowed_chars[0]))
1842        {
1843            $pos = 0;
1844            $len = strlen($allowed_chars);
1845            do
1846            {
1847                $c = $allowed_chars[$pos];
1848
1849                if ($c < "\x80")
1850                {
1851                    /**
1852                    * ASCII char
1853                    */
1854                    $sb_pos = strpos($sb_match, $c);
1855                    if (is_int($sb_pos))
1856                    {
1857                        /**
1858                        * Remove the char from $sb_match and its corresponding
1859                        * replacement in $sb_replace
1860                        */
1861                        $sb_match = substr($sb_match, 0, $sb_pos) . substr($sb_match, $sb_pos + 1);
1862                        $sb_replace = substr($sb_replace, 0, $sb_pos) . substr($sb_replace, $sb_pos + 1);
1863                        $legal_ascii .= $c;
1864                    }
1865
1866                    ++$pos;
1867                }
1868                else
1869                {
1870                    /**
1871                    * UTF-8 char
1872                    */
1873                    $utf_len = $utf_len_mask[$c & "\xF0"];
1874                    $allow[substr($allowed_chars, $pos, $utf_len)] = 1;
1875                    $pos += $utf_len;
1876                }
1877            }
1878            while ($pos < $len);
1879        }
1880
1881        $text = strtr($text, $sb_match, $sb_replace);
1882        $ret = '';
1883
1884        $pos = 0;
1885        $len = strlen($text);
1886
1887        do
1888        {
1889            /**
1890            * Do all consecutive ASCII chars at once
1891            */
1892            if ($spn = strspn($text, $legal_ascii, $pos))
1893            {
1894                $ret .= substr($text, $pos, $spn);
1895                $pos += $spn;
1896            }
1897
1898            if ($pos >= $len)
1899            {
1900                return $ret;
1901            }
1902
1903            /**
1904            * Capture the UTF char
1905            */
1906            $utf_len = $utf_len_mask[$text[$pos] & "\xF0"];
1907            $utf_char = substr($text, $pos, $utf_len);
1908            $pos += $utf_len;
1909
1910            if (($utf_char >= self::UTF8_HANGUL_FIRST && $utf_char <= self::UTF8_HANGUL_LAST)
1911                || ($utf_char >= self::UTF8_CJK_FIRST && $utf_char <= self::UTF8_CJK_LAST)
1912                || ($utf_char >= self::UTF8_CJK_B_FIRST && $utf_char <= self::UTF8_CJK_B_LAST))
1913            {
1914                /**
1915                * All characters within these ranges are valid
1916                *
1917                * We separate them with a space in order to index each character
1918                * individually
1919                */
1920                $ret .= ' ' . $utf_char . ' ';
1921                continue;
1922            }
1923
1924            if (isset($allow[$utf_char]))
1925            {
1926                /**
1927                * The char is explicitly allowed
1928                */
1929                $ret .= $utf_char;
1930                continue;
1931            }
1932
1933            if (isset($conv[$utf_char]))
1934            {
1935                /**
1936                * The char is mapped to something, maybe to itself actually
1937                */
1938                $ret .= $conv[$utf_char];
1939                continue;
1940            }
1941
1942            /**
1943            * The char isn't mapped, but did we load its conversion table?
1944            *
1945            * The search indexer table is split into blocks. The block number of
1946            * each char is equal to its codepoint right-shifted for 11 bits. It
1947            * means that out of the 11, 16 or 21 meaningful bits of a 2-, 3- or
1948            * 4- byte sequence we only keep the leftmost 0, 5 or 10 bits. Thus,
1949            * all UTF chars encoded in 2 bytes are in the same first block.
1950            */
1951            if (isset($utf_char[2]))
1952            {
1953                if (isset($utf_char[3]))
1954                {
1955                    /**
1956                    * 1111 0nnn 10nn nnnn 10nx xxxx 10xx xxxx
1957                    * 0000 0111 0011 1111 0010 0000
1958                    */
1959                    $idx = ((ord($utf_char[0]) & 0x07) << 7) | ((ord($utf_char[1]) & 0x3F) << 1) | ((ord($utf_char[2]) & 0x20) >> 5);
1960                }
1961                else
1962                {
1963                    /**
1964                    * 1110 nnnn 10nx xxxx 10xx xxxx
1965                    * 0000 0111 0010 0000
1966                    */
1967                    $idx = ((ord($utf_char[0]) & 0x07) << 1) | ((ord($utf_char[1]) & 0x20) >> 5);
1968                }
1969            }
1970            else
1971            {
1972                /**
1973                * 110x xxxx 10xx xxxx
1974                * 0000 0000 0000 0000
1975                */
1976                $idx = 0;
1977            }
1978
1979            /**
1980            * Check if the required conv table has been loaded already
1981            */
1982            if (!isset($conv_loaded[$idx]))
1983            {
1984                $conv_loaded[$idx] = 1;
1985                $file = $this->phpbb_root_path . 'includes/utf/data/search_indexer_' . $idx . '.' . $this->php_ext;
1986
1987                if (file_exists($file))
1988                {
1989                    $conv += include($file);
1990                }
1991            }
1992
1993            if (isset($conv[$utf_char]))
1994            {
1995                $ret .= $conv[$utf_char];
1996            }
1997            else
1998            {
1999                /**
2000                * We add an entry to the conversion table so that we
2001                * don't have to convert to codepoint and perform the checks
2002                * that are above this block
2003                */
2004                $conv[$utf_char] = ' ';
2005                $ret .= ' ';
2006            }
2007        }
2008        while (1);
2009
2010        return $ret;
2011    }
2012
2013    /**
2014     * {@inheritdoc}
2015     */
2016    public function get_acp_options(): array
2017    {
2018        /**
2019        * if we need any options, copied from fulltext_native for now, will have to be adjusted or removed
2020        */
2021
2022        $tpl = '
2023        <dl>
2024            <dt><label for="fulltext_native_load_upd">' . $this->language->lang('YES_SEARCH_UPDATE') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('YES_SEARCH_UPDATE_EXPLAIN') . '</span></dt>
2025            <dd><label><input type="radio" id="fulltext_native_load_upd" name="config[fulltext_native_load_upd]" value="1"' . (($this->config['fulltext_native_load_upd']) ? ' checked="checked"' : '') . ' class="radio" /> ' . $this->language->lang('YES') . '</label><label><input type="radio" name="config[fulltext_native_load_upd]" value="0"' . ((!$this->config['fulltext_native_load_upd']) ? ' checked="checked"' : '') . ' class="radio" /> ' . $this->language->lang('NO') . '</label></dd>
2026        </dl>
2027        <dl>
2028            <dt><label for="fulltext_native_min_chars">' . $this->language->lang('MIN_SEARCH_CHARS') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('MIN_SEARCH_CHARS_EXPLAIN') . '</span></dt>
2029            <dd><input id="fulltext_native_min_chars" type="number" min="0" max="255" name="config[fulltext_native_min_chars]" value="' . (int) $this->config['fulltext_native_min_chars'] . '" /></dd>
2030        </dl>
2031        <dl>
2032            <dt><label for="fulltext_native_max_chars">' . $this->language->lang('MAX_SEARCH_CHARS') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('MAX_SEARCH_CHARS_EXPLAIN') . '</span></dt>
2033            <dd><input id="fulltext_native_max_chars" type="number" min="0" max="255" name="config[fulltext_native_max_chars]" value="' . (int) $this->config['fulltext_native_max_chars'] . '" /></dd>
2034        </dl>
2035        <dl>
2036            <dt><label for="fulltext_native_common_thres">' . $this->language->lang('COMMON_WORD_THRESHOLD') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('COMMON_WORD_THRESHOLD_EXPLAIN') . '</span></dt>
2037            <dd><input id="fulltext_native_common_thres" type="text" name="config[fulltext_native_common_thres]" value="' . (float) $this->config['fulltext_native_common_thres'] . '" /> %</dd>
2038        </dl>
2039        ';
2040
2041        // These are fields required in the config table
2042        return array(
2043            'tpl'        => $tpl,
2044            'config'    => array('fulltext_native_load_upd' => 'bool', 'fulltext_native_min_chars' => 'integer:0:255', 'fulltext_native_max_chars' => 'integer:0:255', 'fulltext_native_common_thres' => 'float:0:100')
2045        );
2046    }
2047}