Completed
Push — betterCoreSearch ( bb8ef8...4c924e )
by Michael
04:26
created

Search::getFulltextResultsHTML()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 46
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 30
nc 8
nop 2
dl 0
loc 46
rs 8.4751
c 0
b 0
f 0
1
<?php
2
3
namespace dokuwiki\Ui;
4
5
use \dokuwiki\Form\Form;
6
7
class Search extends Ui
8
{
9
    protected $query;
10
    protected $parsedQuery;
11
    protected $pageLookupResults = array();
12
    protected $fullTextResults = array();
13
    protected $highlight = array();
14
15
    /**
16
     * Search constructor.
17
     *
18
     * @param string $query the search query
19
     */
20
    public function __construct($query)
21
    {
22
        $this->query = $query;
23
        $Indexer = idx_get_indexer();
24
        $this->parsedQuery = ft_queryParser($Indexer, $query);
25
    }
26
27
    /**
28
     * run the search
29
     */
30
    public function execute()
31
    {
32
        $this->pageLookupResults = ft_pageLookup($this->query, true, useHeading('navigation'));
33
        $this->fullTextResults = ft_pageSearch($this->query, $highlight);
34
        $this->highlight = $highlight;
35
    }
36
37
    /**
38
     * display the search result
39
     *
40
     * @return void
41
     */
42
    public function show()
43
    {
44
        $searchHTML = '';
45
46
        $searchHTML .= $this->getSearchFormHTML($this->query);
47
48
        $searchHTML .= $this->getSearchIntroHTML($this->query);
49
50
        $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults);
51
52
        $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight);
53
54
        echo $searchHTML;
55
    }
56
57
    /**
58
     * Get a form which can be used to adjust/refine the search
59
     *
60
     * @param string $query
61
     *
62
     * @return string
63
     */
64
    protected function getSearchFormHTML($query)
65
    {
66
        global $lang;
67
68
        $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form');
69
        $searchForm->setHiddenField('do', 'search');
70
        $searchForm->addFieldsetOpen()->addClass('search-results-form__fieldset');
71
        $searchForm->addTextInput('id')->val($query);
72
        $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit');
73
74
        if ($this->isSearchAssistanceAvailable($this->parsedQuery)) {
75
            $this->addSearchAssistanceElements($searchForm, $this->parsedQuery);
76
        } else {
77
            $searchForm->addClass('search-results-form--no-assistance');
78
            $searchForm->addTagOpen('span')->addClass('search-results-form__no-assistance-message');
79
            $searchForm->addHTML('FIXME Your query is too complex. Search assistance is unavailable. See <a href="https://doku.wiki/search">doku.wiki/search</a> for more help.');
80
            $searchForm->addTagClose('span');
81
        }
82
83
        $searchForm->addFieldsetClose();
84
85
        trigger_event('SEARCH_FORM_DISPLAY', $searchForm);
86
87
        return $searchForm->toHTML();
88
    }
89
90
    /**
91
     * Decide if the given query is simple enough to provide search assistance
92
     *
93
     * @param array $parsedQuery
94
     *
95
     * @return bool
96
     */
97
    protected function isSearchAssistanceAvailable(array $parsedQuery)
98
    {
99
        if (count($parsedQuery['words']) > 1) {
100
            return false;
101
        }
102
        if (!empty($parsedQuery['not'])) {
103
            return false;
104
        }
105
106
        if (!empty($parsedQuery['phrases'])) {
107
            return false;
108
        }
109
110
        if (!empty($parsedQuery['notns'])) {
111
            return false;
112
        }
113
        if (count($parsedQuery['ns']) > 1) {
114
            return false;
115
        }
116
117
        return true;
118
    }
119
120
    /**
121
     * Add the elements to be used for search assistance
122
     *
123
     * @param Form  $searchForm
124
     * @param array $parsedQuery
125
     */
126
    protected function addSearchAssistanceElements(Form $searchForm, array $parsedQuery)
127
    {
128
        $matchType = '';
129
        $searchTerm = null;
130
        if (count($parsedQuery['words']) === 1) {
131
            $searchTerm = $parsedQuery['words'][0];
132
            $firstChar = $searchTerm[0];
133
            $lastChar = substr($searchTerm, -1);
134
            $matchType = 'exact';
135
136
            if ($firstChar === '*') {
137
                $matchType = 'starts';
138
            }
139
            if ($lastChar === '*') {
140
                $matchType = 'ends';
141
            }
142
            if ($firstChar === '*' && $lastChar === '*') {
143
                $matchType = 'contains';
144
            }
145
            $searchTerm = trim($searchTerm, '*');
146
        }
147
148
        $searchForm->addTextInput(
149
            'searchTerm',
150
            '',
151
            $searchForm->findPositionByAttribute('type', 'submit')
0 ignored issues
show
Security Bug introduced by
It seems like $searchForm->findPositio...ibute('type', 'submit') targeting dokuwiki\Form\Form::findPositionByAttribute() can also be of type false; however, dokuwiki\Form\Form::addTextInput() does only seem to accept integer, did you maybe forget to handle an error condition?
Loading history...
152
        )
153
            ->val($searchTerm)
154
            ->attr('style', 'display: none;');
155
        $searchForm->addButton('toggleAssistant', 'toggle search assistant')
156
            ->attr('type', 'button')
157
            ->id('search-results-form__show-assistance-button')
158
            ->addClass('search-results-form__show-assistance-button');
159
160
        $searchForm->addTagOpen('div')
161
            ->addClass('js-advancedSearchOptions')
162
            ->attr('style', 'display: none;');
163
164
        $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper');
165
        $searchForm->addRadioButton('matchType', 'exact Match FM')->val('exact')->attr('checked',
166
            $matchType === 'exact' ?: null);
0 ignored issues
show
Bug introduced by
It seems like $matchType === 'exact' ?: null can also be of type boolean; however, dokuwiki\Form\Element::attr() does only seem to accept null|string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
167
        $searchForm->addRadioButton('matchType', 'starts with FM')->val('starts')->attr('checked',
168
            $matchType === 'starts' ?: null);
0 ignored issues
show
Bug introduced by
It seems like $matchType === 'starts' ?: null can also be of type boolean; however, dokuwiki\Form\Element::attr() does only seem to accept null|string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
169
        $searchForm->addRadioButton('matchType', 'ends with FM')->val('ends')->attr('checked',
170
            $matchType === 'ends' ?: null);
0 ignored issues
show
Bug introduced by
It seems like $matchType === 'ends' ?: null can also be of type boolean; however, dokuwiki\Form\Element::attr() does only seem to accept null|string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
171
        $searchForm->addRadioButton('matchType', 'contains FM')->val('contains')->attr('checked',
172
            $matchType === 'contains' ?: null);
0 ignored issues
show
Bug introduced by
It seems like $matchType === 'contains' ?: null can also be of type boolean; however, dokuwiki\Form\Element::attr() does only seem to accept null|string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
173
        $searchForm->addTagClose('div');
174
175
        $this->addNamespaceSelector($searchForm, $parsedQuery);
176
177
        $searchForm->addTagClose('div');
178
    }
179
180
    /**
181
     * Add the elements for the namespace selector
182
     *
183
     * @param Form  $searchForm
184
     * @param array $parsedQuery
185
     */
186
    protected function addNamespaceSelector(Form $searchForm, array $parsedQuery)
187
    {
188
        $baseNS = empty($parsedQuery['ns']) ? '' : $parsedQuery['ns'][0];
189
        $namespaces = [];
190
        $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper');
191
        if ($baseNS) {
192
            $searchForm->addRadioButton('namespace', '(no namespace FIXME)')->val('');
193
            $parts = [$baseNS => count($this->fullTextResults)];
194
            $upperNameSpace = $baseNS;
195
            while ($upperNameSpace = getNS($upperNameSpace)) {
196
                $parts[$upperNameSpace] = 0;
197
            }
198
            $namespaces = array_reverse($parts);
199
        };
200
201
        $namespaces = array_merge($namespaces, $this->getAdditionalNamespacesFromResults($baseNS));
202
203
        foreach ($namespaces as $extraNS => $count) {
204
            $label = $extraNS . ($count ? " ($count)" : '');
205
            $namespaceCB = $searchForm->addRadioButton('namespace', $label)->val($extraNS);
206
            if ($extraNS === $baseNS) {
207
                $namespaceCB->attr('checked', true);
0 ignored issues
show
Documentation introduced by
true is of type boolean, but the function expects a null|string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
208
            }
209
        }
210
211
        $searchForm->addTagClose('div');
212
    }
213
214
    /**
215
     * Parse the full text results for their top namespaces below the given base namespace
216
     *
217
     * @param string $baseNS the namespace within which was searched, empty string for root namespace
218
     *
219
     * @return array an associative array with namespace => #number of found pages, sorted descending
220
     */
221
    protected function getAdditionalNamespacesFromResults($baseNS)
222
    {
223
        $namespaces = [];
224
        $baseNSLength = strlen($baseNS);
225
        foreach ($this->fullTextResults as $page => $numberOfHits) {
226
            $namespace = getNS($page);
227
            if (!$namespace) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $namespace of type string|false is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
228
                continue;
229
            }
230
            if ($namespace === $baseNS) {
231
                continue;
232
            }
233
            $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
234
            $subtopNS = substr($namespace, 0, $firstColon);
235
            if (empty($namespaces[$subtopNS])) {
236
                $namespaces[$subtopNS] = 0;
237
            }
238
            $namespaces[$subtopNS] += 1;
239
        }
240
        arsort($namespaces);
241
        return $namespaces;
242
    }
243
244
    /**
245
     * Build the intro text for the search page
246
     *
247
     * @param string $query the search query
248
     *
249
     * @return string
250
     */
251
    protected function getSearchIntroHTML($query)
252
    {
253
        global $ID, $lang;
254
255
        $intro = p_locale_xhtml('searchpage');
256
        // allow use of placeholder in search intro
257
        $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : '';
258
        $intro = str_replace(
259
            array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'),
260
            array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo),
261
            $intro
262
        );
263
        return $intro;
264
    }
265
266
    /**
267
     * Build HTML for a list of pages with matching pagenames
268
     *
269
     * @param array $data search results
270
     *
271
     * @return string
272
     */
273
    protected function getPageLookupHTML($data)
274
    {
275
        if (empty($data)) {
276
            return '';
277
        }
278
279
        global $lang;
280
281
        $html = '<div class="search_quickresult">';
282
        $html .= '<h3>' . $lang['quickhits'] . ':</h3>';
283
        $html .= '<ul class="search_quickhits">';
284
        foreach ($data as $id => $title) {
285
            $link = html_wikilink(':' . $id);
286
            $eventData = [
287
                'listItemContent' => [$link],
288
                'page' => $id,
289
            ];
290
            trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData);
291
            $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
292
        }
293
        $html .= '</ul> ';
294
        //clear float (see http://www.complexspiral.com/publications/containing-floats/)
295
        $html .= '<div class="clearer"></div>';
296
        $html .= '</div>';
297
298
        return $html;
299
    }
300
301
    /**
302
     * Build HTML for fulltext search results or "no results" message
303
     *
304
     * @param array $data      the results of the fulltext search
305
     * @param array $highlight the terms to be highlighted in the results
306
     *
307
     * @return string
308
     */
309
    protected function getFulltextResultsHTML($data, $highlight)
310
    {
311
        global $lang;
312
313
        if (empty($data)) {
314
            return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
315
        }
316
317
        $html = '';
318
        $html .= '<dl class="search_results">';
319
        $num = 1;
320
321
        foreach ($data as $id => $cnt) {
322
            $resultLink = html_wikilink(':' . $id, null, $highlight);
323
324
            $resultHeader = [$resultLink];
325
326
            $snippet = '';
327
            if ($cnt !== 0) {
328
                $resultHeader[] = $cnt . ' ' . $lang['hits'];
329
                if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
330
                    $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>';
331
                }
332
                $num++;
333
            }
334
335
            $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id));
336
            if ($restrictQueryToNSLink) {
337
                $resultHeader[] = $restrictQueryToNSLink;
338
            }
339
340
            $eventData = [
341
                'resultHeader' => $resultHeader,
342
                'resultBody' => [$snippet],
343
                'page' => $id,
344
            ];
345
            trigger_event('SEARCH_RESULT_FULLPAGE', $eventData);
346
            $html .= '<div class="search_fullpage_result">';
347
            $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
348
            $html .= implode('', $eventData['resultBody']);
349
            $html .= '</div>';
350
        }
351
        $html .= '</dl>';
352
353
        return $html;
354
    }
355
356
    /**
357
     * create a link to restrict the current query to a namespace
358
     *
359
     * @param bool|string $ns the namespace to which to restrict the query
360
     *
361
     * @return bool|string
362
     */
363
    protected function restrictQueryToNSLink($ns)
364
    {
365
        if (!$ns) {
366
            return false;
367
        }
368
        if (!$this->isSearchAssistanceAvailable($this->parsedQuery)) {
369
            return false;
370
        }
371
        if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) {
372
            return false;
373
        }
374
375
        $newQuery = ft_queryUnparser_simple(
376
            $this->parsedQuery['and'],
0 ignored issues
show
Bug introduced by
It seems like $this->parsedQuery['and'] can also be of type string; however, ft_queryUnparser_simple() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
377
            [],
378
            [],
379
            [$ns],
380
            []
381
        );
382
        $href = wl($newQuery, ['do' => 'search']);
383
        $attributes = buildAttributes([
384
            'rel' => 'nofollow',
385
            'class' => 'search_namespace_link',
386
        ]);
387
        $name = '@' . $ns;
388
        return "<a href=\"$href\" $attributes>$name</a>";
389
    }
390
}
391