Completed
Push — betterCoreSearch ( 4bdf82...2ce8af )
by Michael
03:57
created

Search::createPagenameFromQuery()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 1
dl 0
loc 9
rs 9.6666
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 $searchState;
12
    protected $pageLookupResults = array();
13
    protected $fullTextResults = array();
14
    protected $highlight = array();
15
16
    /**
17
     * Search constructor.
18
     *
19
     * @param array  $pageLookupResults
20
     * @param array  $fullTextResults
21
     * @param string $highlight
22
     */
23
    public function __construct(array $pageLookupResults, array $fullTextResults, $highlight)
24
    {
25
        global $QUERY;
26
        $Indexer = idx_get_indexer();
27
28
        $this->query = $QUERY;
29
        $this->parsedQuery = ft_queryParser($Indexer, $QUERY);
30
        $this->searchState = new SearchState($this->parsedQuery);
31
32
        $this->pageLookupResults = $pageLookupResults;
33
        $this->fullTextResults = $fullTextResults;
34
        $this->highlight = $highlight;
0 ignored issues
show
Documentation Bug introduced by
It seems like $highlight of type string is incompatible with the declared type array of property $highlight.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
35
    }
36
37
    /**
38
     * display the search result
39
     *
40
     * @return void
41
     */
42
    public function show()
43
    {
44
        $searchHTML = '';
45
46
        $searchHTML .= $this->getSearchIntroHTML($this->query);
47
48
        $searchHTML .= $this->getSearchFormHTML($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, $ID, $INPUT;
67
68
        $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form');
69
        $searchForm->setHiddenField('do', 'search');
70
        $searchForm->setHiddenField('id', $ID);
71
        $searchForm->setHiddenField('sf', '1');
72
        if ($INPUT->has('dta')) {
73
            $searchForm->setHiddenField('dta', $INPUT->str('dta'));
74
        }
75
        if ($INPUT->has('dtb')) {
76
            $searchForm->setHiddenField('dtb', $INPUT->str('dtb'));
77
        }
78
        if ($INPUT->has('srt')) {
79
            $searchForm->setHiddenField('srt', $INPUT->str('srt'));
80
        }
81
        $searchForm->addFieldsetOpen()->addClass('search-form');
82
        $searchForm->addTextInput('q')->val($query)->useInput(false);
83
        $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit');
84
85
        $this->addSearchAssistanceElements($searchForm);
86
87
        $searchForm->addFieldsetClose();
88
89
        trigger_event('FORM_SEARCH_OUTPUT', $searchForm);
90
91
        return $searchForm->toHTML();
92
    }
93
94
    protected function addSortTool(Form $searchForm)
95
    {
96
        global $INPUT, $lang;
97
98
        $options = [
99
            'hits' => [
100
                'label' => $lang['search_sort_by_hits'],
101
                'sort' => '',
102
            ],
103
            'mtime' => [
104
                'label' => $lang['search_sort_by_mtime'],
105
                'sort' => 'mtime',
106
            ],
107
        ];
108
        $activeOption = 'hits';
109
110
        if ($INPUT->str('srt') === 'mtime') {
111
            $activeOption = 'mtime';
112
        }
113
114
        $searchForm->addTagOpen('div')->addClass('toggle');
115
        // render current
116
        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
117
        if ($activeOption !== 'hits') {
118
            $currentWrapper->addClass('changed');
119
        }
120
        $searchForm->addHTML($options[$activeOption]['label']);
121
        $searchForm->addTagClose('div');
122
123
        // render options list
124
        $searchForm->addTagOpen('ul');
125
126
        foreach ($options as $key => $option) {
127
            $listItem = $searchForm->addTagOpen('li');
128
129
            if ($key === $activeOption) {
130
                $listItem->addClass('active');
131
                $searchForm->addHTML($option['label']);
132
            } else {
133
                $this->searchState->addSearchLinkSort(
134
                    $searchForm,
135
                    $option['label'],
136
                    $option['sort']
137
                );
138
            }
139
            $searchForm->addTagClose('li');
140
        }
141
        $searchForm->addTagClose('ul');
142
143
        $searchForm->addTagClose('div');
144
145
    }
146
147
    protected function isNamespaceAssistanceAvailable(array $parsedQuery) {
148
        if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
149
            return false;
150
        }
151
152
        return true;
153
    }
154
155
    protected function isFragmentAssistanceAvailable(array $parsedQuery) {
156
        if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
157
            return false;
158
        }
159
160
        if (!empty($parsedQuery['phrases'])) {
161
            return false;
162
        }
163
164
        return true;
165
    }
166
167
    /**
168
     * Add the elements to be used for search assistance
169
     *
170
     * @param Form $searchForm
171
     */
172
    protected function addSearchAssistanceElements(Form $searchForm)
173
    {
174
        // FIXME localize
175
        $searchForm->addButton('toggleAssistant', 'toggle search assistant')
176
            ->attr('type', 'button')
177
            ->addClass('toggleAssistant');
178
179
        $searchForm->addTagOpen('div')
180
            ->addClass('advancedOptions')
181
            ->attr('style', 'display: none;');
182
183
        $this->addFragmentBehaviorLinks($searchForm);
184
        $this->addNamespaceSelector($searchForm);
185
        $this->addDateSelector($searchForm);
186
        $this->addSortTool($searchForm);
187
188
        $searchForm->addTagClose('div');
189
    }
190
191
    protected function addFragmentBehaviorLinks(Form $searchForm)
192
    {
193
        if (!$this->isFragmentAssistanceAvailable($this->parsedQuery)) {
194
            return;
195
        }
196
        global $lang;
197
198
        $options = [
199
            'exact' => [
200
                'label' => $lang['search_exact_match'],
201
                'and' => array_map(function ($term) {
202
                    return trim($term, '*');
203
                }, $this->parsedQuery['and']),
204
                'not' => array_map(function ($term) {
205
                    return trim($term, '*');
206
                }, $this->parsedQuery['not']),
207
            ],
208
            'starts' => [
209
                'label' => $lang['search_starts_with'],
210
                'and' => array_map(function ($term) {
211
                    return trim($term, '*') . '*';
212
                }, $this->parsedQuery['and']),
213
                'not' => array_map(function ($term) {
214
                    return trim($term, '*') . '*';
215
                }, $this->parsedQuery['not']),
216
            ],
217
            'ends' => [
218
                'label' => $lang['search_ends_with'],
219
                'and' => array_map(function ($term) {
220
                    return '*' . trim($term, '*');
221
                }, $this->parsedQuery['and']),
222
                'not' => array_map(function ($term) {
223
                    return '*' . trim($term, '*');
224
                }, $this->parsedQuery['not']),
225
            ],
226
            'contains' => [
227
                'label' => $lang['search_contains'],
228
                'and' => array_map(function ($term) {
229
                    return '*' . trim($term, '*') . '*';
230
                }, $this->parsedQuery['and']),
231
                'not' => array_map(function ($term) {
232
                    return '*' . trim($term, '*') . '*';
233
                }, $this->parsedQuery['not']),
234
            ]
235
        ];
236
237
        // detect current
238
        $activeOption = 'custom';
239
        foreach ($options as $key => $option) {
240
            if ($this->parsedQuery['and'] === $option['and']) {
241
                $activeOption = $key;
242
            }
243
        }
244
        if ($activeOption === 'custom') {
245
            $options = array_merge(['custom' => [
246
                'label' => $lang['search_custom_match'],
247
            ]], $options);
248
        }
249
250
        $searchForm->addTagOpen('div')->addClass('toggle');
251
        // render current
252
        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
253
        if ($activeOption !== 'exact') {
254
            $currentWrapper->addClass('changed');
255
        }
256
        $searchForm->addHTML($options[$activeOption]['label']);
257
        $searchForm->addTagClose('div');
258
259
        // render options list
260
        $searchForm->addTagOpen('ul');
261
262
        foreach ($options as $key => $option) {
263
            $listItem = $searchForm->addTagOpen('li');
264
265
            if ($key === $activeOption) {
266
                $listItem->addClass('active');
267
                $searchForm->addHTML($option['label']);
268
            } else {
269
                $this->searchState->addSearchLinkFragment(
270
                    $searchForm,
271
                    $option['label'],
272
                    $option['and'],
273
                    $option['not']
274
                );
275
            }
276
            $searchForm->addTagClose('li');
277
        }
278
        $searchForm->addTagClose('ul');
279
280
        $searchForm->addTagClose('div');
281
282
        // render options list
283
    }
284
285
    /**
286
     * Add the elements for the namespace selector
287
     *
288
     * @param Form $searchForm
289
     */
290
    protected function addNamespaceSelector(Form $searchForm)
291
    {
292
        if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) {
293
            return;
294
        }
295
296
        global $lang;
297
298
        $baseNS = empty($this->parsedQuery['ns']) ? '' : $this->parsedQuery['ns'][0];
299
        $extraNS = $this->getAdditionalNamespacesFromResults($baseNS);
300
301
        $searchForm->addTagOpen('div')->addClass('toggle');
302
        // render current
303
        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
304
        if ($baseNS) {
305
            $currentWrapper->addClass('changed');
306
            $searchForm->addHTML('@' . $baseNS);
307
        } else {
308
            $searchForm->addHTML($lang['search_any_ns']);
309
        }
310
        $searchForm->addTagClose('div');
311
312
        // render options list
313
        $searchForm->addTagOpen('ul');
314
315
        $listItem = $searchForm->addTagOpen('li');
316
        if ($baseNS) {
317
            $listItem->addClass('active');
318
            $this->searchState->addSeachLinkNS(
319
                $searchForm,
320
                $lang['search_any_ns'],
321
                ''
322
            );
323
        } else {
324
            $searchForm->addHTML($lang['search_any_ns']);
325
        }
326
        $searchForm->addTagClose('li');
327
328
        foreach ($extraNS as $ns => $count) {
329
            $listItem = $searchForm->addTagOpen('li');
330
            $label = $ns . ($count ? " ($count)" : '');
331
332
            if ($ns === $baseNS) {
333
                $listItem->addClass('active');
334
                $searchForm->addHTML($label);
335
            } else {
336
                $this->searchState->addSeachLinkNS(
337
                    $searchForm,
338
                    $label,
339
                    $ns
340
                );
341
            }
342
            $searchForm->addTagClose('li');
343
        }
344
        $searchForm->addTagClose('ul');
345
346
        $searchForm->addTagClose('div');
347
348
    }
349
350
    /**
351
     * Parse the full text results for their top namespaces below the given base namespace
352
     *
353
     * @param string $baseNS the namespace within which was searched, empty string for root namespace
354
     *
355
     * @return array an associative array with namespace => #number of found pages, sorted descending
356
     */
357
    protected function getAdditionalNamespacesFromResults($baseNS)
358
    {
359
        $namespaces = [];
360
        $baseNSLength = strlen($baseNS);
361
        foreach ($this->fullTextResults as $page => $numberOfHits) {
362
            $namespace = getNS($page);
363
            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...
364
                continue;
365
            }
366
            if ($namespace === $baseNS) {
367
                continue;
368
            }
369
            $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
370
            $subtopNS = substr($namespace, 0, $firstColon);
371
            if (empty($namespaces[$subtopNS])) {
372
                $namespaces[$subtopNS] = 0;
373
            }
374
            $namespaces[$subtopNS] += 1;
375
        }
376
        ksort($namespaces);
377
        arsort($namespaces);
378
        return $namespaces;
379
    }
380
381
    /**
382
     * @ToDo: custom date input
383
     *
384
     * @param Form $searchForm
385
     */
386
    protected function addDateSelector(Form $searchForm)
387
    {
388
        global $INPUT, $lang;
389
390
        $options = [
391
            'any' => [
392
                'before' => false,
393
                'after' => false,
394
                'label' => $lang['search_any_time'],
395
            ],
396
            'week' => [
397
                'before' => false,
398
                'after' => '1 week ago',
399
                'label' => $lang['search_past_7_days'],
400
            ],
401
            'month' => [
402
                'before' => false,
403
                'after' => '1 month ago',
404
                'label' => $lang['search_past_month'],
405
            ],
406
            'year' => [
407
                'before' => false,
408
                'after' => '1 year ago',
409
                'label' => $lang['search_past_year'],
410
            ],
411
        ];
412
        $activeOption = 'any';
413
        foreach ($options as $key => $option) {
414
            if ($INPUT->str('dta') === $option['after']) {
415
                $activeOption = $key;
416
                break;
417
            }
418
        }
419
420
        $searchForm->addTagOpen('div')->addClass('toggle');
421
        // render current
422
        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
423
        if ($INPUT->has('dtb') || $INPUT->has('dta')) {
424
            $currentWrapper->addClass('changed');
425
        }
426
        $searchForm->addHTML($options[$activeOption]['label']);
427
        $searchForm->addTagClose('div');
428
429
        // render options list
430
        $searchForm->addTagOpen('ul');
431
432
        foreach ($options as $key => $option) {
433
            $listItem = $searchForm->addTagOpen('li');
434
435
            if ($key === $activeOption) {
436
                $listItem->addClass('active');
437
                $searchForm->addHTML($option['label']);
438
            } else {
439
                $this->searchState->addSearchLinkTime(
440
                    $searchForm,
441
                    $option['label'],
442
                    $option['after'],
443
                    $option['before']
444
                );
445
            }
446
            $searchForm->addTagClose('li');
447
        }
448
        $searchForm->addTagClose('ul');
449
450
        $searchForm->addTagClose('div');
451
    }
452
453
454
    /**
455
     * Build the intro text for the search page
456
     *
457
     * @param string $query the search query
458
     *
459
     * @return string
460
     */
461
    protected function getSearchIntroHTML($query)
462
    {
463
        global $lang;
464
465
        $intro = p_locale_xhtml('searchpage');
466
467
        $queryPagename = $this->createPagenameFromQuery($this->parsedQuery);
468
        $createQueryPageLink = html_wikilink($queryPagename . '?do=edit', $queryPagename);
469
470
        $pagecreateinfo = '';
471
        if (auth_quickaclcheck($queryPagename) >= AUTH_CREATE) {
472
            $pagecreateinfo = sprintf($lang['searchcreatepage'], $createQueryPageLink);
473
        }
474
        $intro = str_replace(
475
            array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'),
476
            array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo),
477
            $intro
478
        );
479
480
        return $intro;
481
    }
482
483
    /**
484
     * Create a pagename based the parsed search query
485
     *
486
     * @param array $parsedQuery
487
     *
488
     * @return string pagename constructed from the parsed query
489
     */
490
    protected function createPagenameFromQuery($parsedQuery)
491
    {
492
        $pagename = '';
493
        if (!empty($parsedQuery['ns'])) {
494
            $pagename .= cleanID($parsedQuery['ns'][0]);
495
        }
496
        $pagename .= ':' . cleanID(implode(' ' , $parsedQuery['highlight']));
497
        return $pagename;
498
    }
499
500
    /**
501
     * Build HTML for a list of pages with matching pagenames
502
     *
503
     * @param array $data search results
504
     *
505
     * @return string
506
     */
507
    protected function getPageLookupHTML($data)
508
    {
509
        if (empty($data)) {
510
            return '';
511
        }
512
513
        global $lang;
514
515
        $html = '<div class="search_quickresult">';
516
        $html .= '<h3>' . $lang['quickhits'] . ':</h3>';
517
        $html .= '<ul class="search_quickhits">';
518
        foreach ($data as $id => $title) {
519
            $link = html_wikilink(':' . $id);
520
            $eventData = [
521
                'listItemContent' => [$link],
522
                'page' => $id,
523
            ];
524
            trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData);
525
            $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
526
        }
527
        $html .= '</ul> ';
528
        //clear float (see http://www.complexspiral.com/publications/containing-floats/)
529
        $html .= '<div class="clearer"></div>';
530
        $html .= '</div>';
531
532
        return $html;
533
    }
534
535
    /**
536
     * Build HTML for fulltext search results or "no results" message
537
     *
538
     * @param array $data      the results of the fulltext search
539
     * @param array $highlight the terms to be highlighted in the results
540
     *
541
     * @return string
542
     */
543
    protected function getFulltextResultsHTML($data, $highlight)
544
    {
545
        global $lang;
546
547
        if (empty($data)) {
548
            return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
549
        }
550
551
        $html = '<div class="search_fulltextresult">';
552
        $html .= '<h3>' . $lang['search_fullresults'] . ':</h3>';
553
554
        $html .= '<dl class="search_results">';
555
        $num = 1;
556
557
        foreach ($data as $id => $cnt) {
558
            $resultLink = html_wikilink(':' . $id, null, $highlight);
559
560
            $resultHeader = [$resultLink];
561
562
563
            $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id));
564
            if ($restrictQueryToNSLink) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $restrictQueryToNSLink of type false|string is loosely compared to true; 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...
565
                $resultHeader[] = $restrictQueryToNSLink;
566
            }
567
568
            $snippet = '';
569
            $lastMod = '';
570
            $mtime = filemtime(wikiFN($id));
571
            if ($cnt !== 0) {
572
                $resultHeader[] = $cnt . ' ' . $lang['hits'];
573
                if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
574
                    $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>';
575
                    $lastMod = '<span class="search_results__lastmod">' . $lang['lastmod'] . ' ';
576
                    $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">' . dformat($mtime) . '</time>';
577
                    $lastMod .= '</span>';
578
                }
579
                $num++;
580
            }
581
582
            $metaLine = '<div class="search_results__metaLine">';
583
            $metaLine .= $lastMod;
584
            $metaLine .= '</div>';
585
586
587
            $eventData = [
588
                'resultHeader' => $resultHeader,
589
                'resultBody' => [$metaLine, $snippet],
590
                'page' => $id,
591
            ];
592
            trigger_event('SEARCH_RESULT_FULLPAGE', $eventData);
593
            $html .= '<div class="search_fullpage_result">';
594
            $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
595
            $html .= implode('', $eventData['resultBody']);
596
            $html .= '</div>';
597
        }
598
        $html .= '</dl>';
599
600
        $html .= '</div>';
601
602
        return $html;
603
    }
604
605
    /**
606
     * create a link to restrict the current query to a namespace
607
     *
608
     * @param bool|string $ns the namespace to which to restrict the query
609
     *
610
     * @return bool|string
611
     */
612
    protected function restrictQueryToNSLink($ns)
613
    {
614
        if (!$ns) {
615
            return false;
616
        }
617
        if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) {
618
            return false;
619
        }
620
        if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) {
621
            return false;
622
        }
623
        $name = '@' . $ns;
624
        $tmpForm = new Form();
625
        $this->searchState->addSeachLinkNS($tmpForm, $name, $ns);
0 ignored issues
show
Bug introduced by
It seems like $ns defined by parameter $ns on line 612 can also be of type boolean; however, dokuwiki\Ui\SearchState::addSeachLinkNS() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
626
        return $tmpForm->toHTML();
627
    }
628
}
629