Completed
Push — betterCoreSearch ( e192d6...4d0cb6 )
by Michael
04:17
created

Search::show()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 7
nc 1
nop 0
dl 0
loc 14
rs 9.4285
c 1
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
0 ignored issues
show
Bug introduced by
There is no parameter named $query. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
19
     */
20
    public function __construct()
21
    {
22
        global $QUERY;
23
24
        $Indexer = idx_get_indexer();
25
        $parsedQuery = ft_queryParser($Indexer, $QUERY);
26
27
        $this->query = $QUERY;
28
        $this->parsedQuery = $parsedQuery;
29
    }
30
31
    /**
32
     * run the search
33
     */
34
    public function execute()
35
    {
36
        $this->pageLookupResults = ft_pageLookup($this->query, true, useHeading('navigation'));
37
        $this->fullTextResults = ft_pageSearch($this->query, $highlight);
38
        $this->highlight = $highlight;
39
    }
40
41
    /**
42
     * display the search result
43
     *
44
     * @return void
45
     */
46
    public function show()
47
    {
48
        $searchHTML = '';
49
50
        $searchHTML .= $this->getSearchFormHTML($this->query);
51
52
        $searchHTML .= $this->getSearchIntroHTML($this->query);
53
54
        $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults);
55
56
        $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight);
57
58
        echo $searchHTML;
59
    }
60
61
    /**
62
     * Get a form which can be used to adjust/refine the search
63
     *
64
     * @param string $query
65
     *
66
     * @return string
67
     */
68
    protected function getSearchFormHTML($query)
69
    {
70
        global $lang, $ID;
71
72
        $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form');
73
        $searchForm->setHiddenField('do', 'search');
74
        $searchForm->setHiddenField('from', $ID);
75
        $searchForm->setHiddenField('searchPageForm', '1');
76
        $searchForm->addFieldsetOpen()->addClass('search-results-form__fieldset');
77
        $searchForm->addTextInput('id')->val($query)->useInput(false);
78
        $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit');
79
80
        if ($this->isSearchAssistanceAvailable($this->parsedQuery)) {
81
            $this->addSearchAssistanceElements($searchForm, $this->parsedQuery);
82
        } else {
83
            $searchForm->addClass('search-results-form--no-assistance');
84
            $searchForm->addTagOpen('span')->addClass('search-results-form__no-assistance-message');
85
            $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.');
86
            $searchForm->addTagClose('span');
87
        }
88
89
        $searchForm->addFieldsetClose();
90
91
        trigger_event('SEARCH_FORM_DISPLAY', $searchForm);
92
93
        return $searchForm->toHTML();
94
    }
95
96
    /**
97
     * Decide if the given query is simple enough to provide search assistance
98
     *
99
     * @param array $parsedQuery
100
     *
101
     * @return bool
102
     */
103
    protected function isSearchAssistanceAvailable(array $parsedQuery)
104
    {
105
        if (count($parsedQuery['words']) > 1) {
106
            return false;
107
        }
108
        if (!empty($parsedQuery['not'])) {
109
            return false;
110
        }
111
112
        if (!empty($parsedQuery['phrases'])) {
113
            return false;
114
        }
115
116
        if (!empty($parsedQuery['notns'])) {
117
            return false;
118
        }
119
        if (count($parsedQuery['ns']) > 1) {
120
            return false;
121
        }
122
123
        return true;
124
    }
125
126
    /**
127
     * Add the elements to be used for search assistance
128
     *
129
     * @param Form  $searchForm
130
     * @param array $parsedQuery
131
     */
132
    protected function addSearchAssistanceElements(Form $searchForm, array $parsedQuery)
133
    {
134
        $searchForm->addButton('toggleAssistant', 'toggle search assistant')
135
            ->attr('type', 'button')
136
            ->id('search-results-form__show-assistance-button')
137
            ->addClass('search-results-form__show-assistance-button');
138
139
        $searchForm->addTagOpen('div')
140
            ->addClass('js-advancedSearchOptions')
141
            ->attr('style', 'display: none;');
142
143
        $this->addFragmentBehaviorLinks($searchForm, $parsedQuery);
144
        $this->addNamespaceSelector($searchForm, $parsedQuery);
145
146
        $searchForm->addTagClose('div');
147
    }
148
149
    protected function addFragmentBehaviorLinks(Form $searchForm, array $parsedQuery)
0 ignored issues
show
Unused Code introduced by
The parameter $parsedQuery is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
150
    {
151
        $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper');
152
153
        $this->addSearchLink(
154
            $searchForm,
155
            'exact Match',
156
            array_map(function($term){return trim($term, '*');},$this->parsedQuery['and']),
157
            $this->parsedQuery['ns']
158
        );
159
160
        $searchForm->addHTML(' ');
161
162
        $this->addSearchLink(
163
            $searchForm,
164
            'starts with',
165
            array_map(function($term){return trim($term, '*') . '*';},$this->parsedQuery['and']),
166
            $this->parsedQuery['ns']
167
        );
168
169
        $searchForm->addHTML(' ');
170
171
        $this->addSearchLink(
172
            $searchForm,
173
            'ends with',
174
            array_map(function($term){return '*' . trim($term, '*');},$this->parsedQuery['and']),
175
            $this->parsedQuery['ns']
176
        );
177
178
        $searchForm->addHTML(' ');
179
180
        $this->addSearchLink(
181
            $searchForm,
182
            'contains',
183
            array_map(function($term){return '*' . trim($term, '*') . '*';},$this->parsedQuery['and']),
184
            $this->parsedQuery['ns']
185
        );
186
187
        $searchForm->addTagClose('div');
188
    }
189
190
    protected function addSearchLink(Form $searchForm, $label, $and, $ns) {
191
        $newQuery = ft_queryUnparser_simple(
192
            $and,
193
            [],
194
            [],
195
            $ns,
196
            []
197
        );
198
        $searchForm->addTagOpen('a')
199
            ->attrs([
200
                'href' => wl($newQuery, ['do' => 'search', 'searchPageForm' => '1'], false, '&')
201
            ])
202
        ;
203
        $searchForm->addHTML($label);
204
        $searchForm->addTagClose('a');
205
    }
206
207
    /**
208
     * Add the elements for the namespace selector
209
     *
210
     * @param Form  $searchForm
211
     * @param array $parsedQuery
212
     */
213
    protected function addNamespaceSelector(Form $searchForm, array $parsedQuery)
214
    {
215
        $baseNS = empty($parsedQuery['ns']) ? '' : $parsedQuery['ns'][0];
216
        $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper');
217
218
        if ($baseNS) {
219
            $searchForm->addTagOpen('div');
220
221
            $this->addSearchLink(
222
                $searchForm,
223
                'remove current namespace restriction',
224
                $this->parsedQuery['and'],
225
                []
226
            );
227
228
            $searchForm->addTagClose('div');
229
        }
230
231
        $extraNS = $this->getAdditionalNamespacesFromResults($baseNS);
232
        if (!empty($extraNS)) {
233
            $searchForm->addTagOpen('div');
234
            $searchForm->addHTML('first level ns below current: ');
235
236
            foreach ($extraNS as $extraNS => $count) {
0 ignored issues
show
Bug introduced by
The expression $extraNS of type integer|string|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
237
                $searchForm->addHTML(' ');
238
                $label = $extraNS . ($count ? " ($count)" : '');
239
240
                $this->addSearchLink($searchForm, $label, $this->parsedQuery['and'], [$extraNS]);
241
            }
242
            $searchForm->addTagClose('div');
243
        }
244
245
        $searchForm->addTagClose('div');
246
    }
247
248
    /**
249
     * Parse the full text results for their top namespaces below the given base namespace
250
     *
251
     * @param string $baseNS the namespace within which was searched, empty string for root namespace
252
     *
253
     * @return array an associative array with namespace => #number of found pages, sorted descending
254
     */
255
    protected function getAdditionalNamespacesFromResults($baseNS)
256
    {
257
        $namespaces = [];
258
        $baseNSLength = strlen($baseNS);
259
        foreach ($this->fullTextResults as $page => $numberOfHits) {
260
            $namespace = getNS($page);
261
            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...
262
                continue;
263
            }
264
            if ($namespace === $baseNS) {
265
                continue;
266
            }
267
            $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
268
            $subtopNS = substr($namespace, 0, $firstColon);
269
            if (empty($namespaces[$subtopNS])) {
270
                $namespaces[$subtopNS] = 0;
271
            }
272
            $namespaces[$subtopNS] += 1;
273
        }
274
        arsort($namespaces);
275
        return $namespaces;
276
    }
277
278
    /**
279
     * Build the intro text for the search page
280
     *
281
     * @param string $query the search query
282
     *
283
     * @return string
284
     */
285
    protected function getSearchIntroHTML($query)
286
    {
287
        global $ID, $lang;
288
289
        $intro = p_locale_xhtml('searchpage');
290
        // allow use of placeholder in search intro
291
        $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : '';
292
        $intro = str_replace(
293
            array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'),
294
            array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo),
295
            $intro
296
        );
297
        return $intro;
298
    }
299
300
    /**
301
     * Build HTML for a list of pages with matching pagenames
302
     *
303
     * @param array $data search results
304
     *
305
     * @return string
306
     */
307
    protected function getPageLookupHTML($data)
308
    {
309
        if (empty($data)) {
310
            return '';
311
        }
312
313
        global $lang;
314
315
        $html = '<div class="search_quickresult">';
316
        $html .= '<h3>' . $lang['quickhits'] . ':</h3>';
317
        $html .= '<ul class="search_quickhits">';
318
        foreach ($data as $id => $title) {
319
            $link = html_wikilink(':' . $id);
320
            $eventData = [
321
                'listItemContent' => [$link],
322
                'page' => $id,
323
            ];
324
            trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData);
325
            $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
326
        }
327
        $html .= '</ul> ';
328
        //clear float (see http://www.complexspiral.com/publications/containing-floats/)
329
        $html .= '<div class="clearer"></div>';
330
        $html .= '</div>';
331
332
        return $html;
333
    }
334
335
    /**
336
     * Build HTML for fulltext search results or "no results" message
337
     *
338
     * @param array $data      the results of the fulltext search
339
     * @param array $highlight the terms to be highlighted in the results
340
     *
341
     * @return string
342
     */
343
    protected function getFulltextResultsHTML($data, $highlight)
344
    {
345
        global $lang;
346
347
        if (empty($data)) {
348
            return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
349
        }
350
351
        $html = '';
352
        $html .= '<dl class="search_results">';
353
        $num = 1;
354
355
        foreach ($data as $id => $cnt) {
356
            $resultLink = html_wikilink(':' . $id, null, $highlight);
357
358
            $resultHeader = [$resultLink];
359
360
            $snippet = '';
361
            if ($cnt !== 0) {
362
                $resultHeader[] = $cnt . ' ' . $lang['hits'];
363
                if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
364
                    $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>';
365
                }
366
                $num++;
367
            }
368
369
            $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id));
370
            if ($restrictQueryToNSLink) {
371
                $resultHeader[] = $restrictQueryToNSLink;
372
            }
373
374
            $eventData = [
375
                'resultHeader' => $resultHeader,
376
                'resultBody' => [$snippet],
377
                'page' => $id,
378
            ];
379
            trigger_event('SEARCH_RESULT_FULLPAGE', $eventData);
380
            $html .= '<div class="search_fullpage_result">';
381
            $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
382
            $html .= implode('', $eventData['resultBody']);
383
            $html .= '</div>';
384
        }
385
        $html .= '</dl>';
386
387
        return $html;
388
    }
389
390
    /**
391
     * create a link to restrict the current query to a namespace
392
     *
393
     * @param bool|string $ns the namespace to which to restrict the query
394
     *
395
     * @return bool|string
396
     */
397
    protected function restrictQueryToNSLink($ns)
398
    {
399
        if (!$ns) {
400
            return false;
401
        }
402
        if (!$this->isSearchAssistanceAvailable($this->parsedQuery)) {
403
            return false;
404
        }
405
        if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) {
406
            return false;
407
        }
408
409
        $newQuery = ft_queryUnparser_simple(
410
            $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...
411
            [],
412
            [],
413
            [$ns],
414
            []
415
        );
416
        $href = wl($newQuery, ['do' => 'search', 'searchPageForm' => '1']);
417
        $attributes = buildAttributes([
418
            'rel' => 'nofollow',
419
            'class' => 'search_namespace_link',
420
        ]);
421
        $name = '@' . $ns;
422
        return "<a href=\"$href\" $attributes>$name</a>";
423
    }
424
}
425