Passed
Push — master ( 40f9bb...4d159e )
by Nicolaas
04:11
created

SearchApi::doReplacement()   B

Complexity

Conditions 11
Paths 3

Size

Total Lines 44
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 11
eloc 29
nc 3
nop 3
dl 0
loc 44
rs 7.3166
c 3
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Sunnysideup\SiteWideSearch\Api;
4
5
use SilverStripe\Core\ClassInfo;
6
use SilverStripe\Core\Config\Configurable;
7
use SilverStripe\Core\Convert;
8
use SilverStripe\Core\Extensible;
9
use SilverStripe\Core\Injector\Injectable;
10
use SilverStripe\Core\Injector\Injector;
11
use SilverStripe\ORM\ArrayList;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\DB;
14
use SilverStripe\ORM\FieldType\DBDatetime;
15
use SilverStripe\ORM\FieldType\DBField;
16
use SilverStripe\Security\LoginAttempt;
17
use SilverStripe\Security\MemberPassword;
18
use SilverStripe\Security\RememberLoginHash;
19
use SilverStripe\SessionManager\Models\LoginSession;
20
use SilverStripe\Versioned\ChangeSet;
21
use SilverStripe\Versioned\ChangeSetItem;
22
use SilverStripe\Versioned\Versioned;
23
use SilverStripe\View\ArrayData;
24
use Sunnysideup\SiteWideSearch\Helpers\FindClassesAndFields;
25
use Sunnysideup\SiteWideSearch\Helpers\FindEditableObjects;
26
27
class SearchApi
28
{
29
    use Extensible;
30
    use Configurable;
31
    use Injectable;
32
33
    protected $debug = false;
34
35
    protected $isQuickSearch = false;
36
37
    protected $searchWholePhrase = false;
38
39
    protected $baseClass = DataObject::class;
40
41
    protected $quickSearchType = 'limited';
42
43
    protected $excludedClasses = [];
44
45
    protected $excludedClassesWithSubClassess = [];
46
47
    protected $includedClasses = [];
48
49
    protected $includedClassesWithSubClassess = [];
50
51
    protected $excludedFields = [];
52
53
    protected $includedFields = [];
54
55
    protected $includedClassFieldCombos = [];
56
57
    protected $defaultLists = [];
58
59
    protected $sortOverride;
60
61
    protected $words = [];
62
63
    protected $replace = '';
64
65
    private $objects = [];
66
67
    private static $limit_of_count_per_data_object = 999;
68
69
    private static $hours_back_for_recent = 48;
70
71
    private static $limit_per_class_for_recent = 5;
72
73
    private static $default_exclude_classes = [
74
        MemberPassword::class,
75
        LoginAttempt::class,
76
        ChangeSet::class,
77
        ChangeSetItem::class,
78
        RememberLoginHash::class,
79
        LoginSession::class,
80
    ];
81
82
    private static $default_exclude_fields = [
83
        'ClassName',
84
        'LastEdited',
85
        'Created',
86
        'ID',
87
        'CanViewType',
88
        'CanEditType',
89
    ];
90
91
    private static $default_include_classes = [];
92
93
    private static $default_include_fields = [];
94
95
    private static $default_include_class_field_combos = [];
96
97
    private static $default_lists = [];
98
99
    public function setDebug(bool $b): SearchApi
100
    {
101
        $this->debug = $b;
102
103
        return $this;
104
    }
105
106
    protected function getCache()
107
    {
108
        return FindClassesAndFields::inst($this->baseClass);
109
    }
110
111
    public function setQuickSearchType(string $nameOrType): SearchApi
112
    {
113
        if ($nameOrType === 'all') {
114
            $this->isQuickSearch = false;
115
            $this->quickSearchType = '';
116
        } elseif ($nameOrType === 'limited') {
117
            $this->isQuickSearch = true;
118
            $this->quickSearchType = '';
119
        } elseif (class_exists($nameOrType)) {
120
            $this->quickSearchType = $nameOrType;
121
            $object = Injector::inst()->get($nameOrType);
122
            $this->setIncludedClasses($object->getClassesToSearch());
123
            $this->setIncludedFields($object->getFieldsToSearch());
124
            $this->setIncludedClassFieldCombos($object->getIncludedClassFieldCombos());
125
            $this->setDefaultLists($object->getDefaultLists());
126
            $this->setSortOverride($object->getSortOverride());
127
        } else {
128
            user_error('QuickSearchType must be either "all" or "limited" or a defined quick search class. Provided was: ' . $nameOrType);
129
        }
130
131
        return $this;
132
    }
133
134
    public function setIsQuickSearch(bool $b): SearchApi
135
    {
136
        $this->isQuickSearch = $b;
137
138
        return $this;
139
    }
140
141
    public function setSearchWholePhrase(bool $b): SearchApi
142
    {
143
        $this->searchWholePhrase = $b;
144
145
        return $this;
146
    }
147
148
    public function setBaseClass(string $class): SearchApi
149
    {
150
        if (class_exists($class)) {
151
            $this->baseClass = $class;
152
        }
153
154
        return $this;
155
    }
156
157
    public function setExcludedClasses(array $a): SearchApi
158
    {
159
        $this->excludedClasses = $a;
160
161
        return $this;
162
    }
163
164
    public function setIncludedClasses(array $a): SearchApi
165
    {
166
        $this->includedClasses = $a;
167
        return $this;
168
    }
169
170
    public function setExcludedFields(array $a): SearchApi
171
    {
172
        $this->excludedFields = $a;
173
174
        return $this;
175
    }
176
177
    public function setIncludedFields(array $a): SearchApi
178
    {
179
        $this->includedFields = $a;
180
181
        return $this;
182
    }
183
184
    public function setIncludedClassFieldCombos(array $a): SearchApi
185
    {
186
        $this->includedClassFieldCombos = $a;
187
188
        return $this;
189
    }
190
191
    public function setDefaultLists(array $a): SearchApi
192
    {
193
        $this->defaultLists = $a;
194
195
        return $this;
196
    }
197
198
    public function setSortOverride(?array $a = null): SearchApi
199
    {
200
        $this->sortOverride = $a;
201
202
        return $this;
203
    }
204
205
    public function setWordsAsString(string $s): SearchApi
206
    {
207
        $s = $this->securityCheckInput($s);
208
        $this->words = explode(' ', $s);
209
210
        return $this;
211
    }
212
213
    // public function __construct()
214
    // {
215
    //     Environment::increaseTimeLimitTo(300);
216
    //     Environment::setMemoryLimitMax(-1);
217
    //     Environment::increaseMemoryLimitTo(-1);
218
    // }
219
220
    protected $cacheHasBeenBuilt;
221
222
    public function buildCache(?string $word = ''): SearchApi
223
    {
224
        if ($this->cacheHasBeenBuilt !== $word) {
225
            $this->getLinksInner($word);
226
            $this->cacheHasBeenBuilt = $word;
227
        }
228
        return $this;
229
    }
230
231
    public function getLinks(?string $word = ''): ArrayList
232
    {
233
        return $this->getLinksInner($word);
234
    }
235
236
    protected function getLinksInner(?string $word = ''): ArrayList
237
    {
238
        $this->initCache();
239
240
        //always do first ...
241
        $matches = $this->getMatches($word);
242
243
        $list = $this->turnMatchesIntoList($matches);
244
245
        $this->saveCache();
246
        return $list;
247
    }
248
249
250
251
    public function doReplacementURL(string $word, string $replace, ?bool $isURL = false): int
0 ignored issues
show
Unused Code introduced by
The parameter $isURL is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

251
    public function doReplacementURL(string $word, string $replace, /** @scrutinizer ignore-unused */ ?bool $isURL = false): int

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

Loading history...
252
    {
253
        return $this->doReplacement($word, $replace, 'url');
254
    }
255
256
    public function doReplacement(string $word, string $replace, ?string $type = ''): int
257
    {
258
        $count = 0;
259
        if ($word !== '' && $word !== '0') {
260
            $this->buildCache($word);
261
            $replace = $this->securityCheckInput($replace);
262
            if (strpos('://', $word) !== false) {
263
                $type = 'url';
264
            }
265
            foreach ($this->objects as $item) {
266
                $className = $item->ClassName;
267
                if ($item->canEdit()) {
268
                    $fields = $this->getAllValidFields($className);
269
                    foreach ($fields as $field) {
270
                        if (! $this->includeFieldTest($className, $field)) {
271
                            continue;
272
                        }
273
                        if ($type === 'url') {
274
                            $escapedFrom = preg_quote($word, '/');
275
                            // It replaces exact matches of $escapedFrom (with optional trailing slash) in $item->{$field} only if followed by space, quote, ?, #, or end of string, preserving the slash if present.
276
                            $new = preg_replace_callback(
277
                                '/\b' . $escapedFrom . '(\/?)(?=[\s"\']|\?|#|$)/',
278
                                fn($matches) => $replace . ($matches[1] ?? ''),
279
                                $item->{$field}
280
                            );
281
                        } else {
282
                            $new = str_replace($word, $replace, $item->{$field});
283
                        }
284
                        if ($new === $item->{$field}) {
285
                            continue;
286
                        }
287
                        ++$count;
288
                        $item->{$field} = $new;
289
                        $this->writeAndPublishIfAppropriate($item);
290
291
                        if ($this->debug) {
292
                            DB::alteration_message('<h2>Match:  ' . $item->ClassName . $item->ID . '</h2>' . $new . '<hr />');
293
                        }
294
                    }
295
                }
296
            }
297
        }
298
299
        return $count;
300
    }
301
302
    protected function saveCache(): self
303
    {
304
        $this->getCache()->saveCache();
305
306
        return $this;
307
    }
308
309
    protected function initCache(): self
310
    {
311
        $this->getCache()->initCache();
312
313
        return $this;
314
    }
315
316
    protected function writeAndPublishIfAppropriate($item)
317
    {
318
        if ($item->hasExtension(Versioned::class)) {
319
            $myStage = Versioned::get_stage();
320
            Versioned::set_stage(Versioned::DRAFT);
321
            // is it on live and is live the same as draft
322
            $canBePublished = $item->isPublished() && ! $item->isModifiedOnDraft();
323
            $item->writeToStage(Versioned::DRAFT);
324
            if ($canBePublished) {
325
                $item->publishSingle();
326
            }
327
            Versioned::set_stage($myStage);
328
        } else {
329
            $item->write();
330
        }
331
    }
332
333
    protected function getMatches(?string $word = ''): array
334
    {
335
        $startInner = 0;
336
        $startOuter = 0;
337
        if ($this->debug) {
338
            $startOuter = microtime(true);
339
        }
340
        $this->workOutInclusionsAndExclusions();
341
342
        // important to do this first
343
        if ($word) {
344
            $this->setWordsAsString($word);
345
        }
346
        $this->workOutWordsForSearching();
347
        if ($this->debug) {
348
            DB::alteration_message('Words searched for ' . print_r($this->words, 1));
0 ignored issues
show
Bug introduced by
Are you sure print_r($this->words, 1) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

348
            DB::alteration_message('Words searched for ' . /** @scrutinizer ignore-type */ print_r($this->words, 1));
Loading history...
349
        }
350
351
        $array = [];
352
353
        if (count($this->words) > 0) {
354
            foreach ($this->getAllDataObjects() as $className) {
355
                if (! $this->includeClassTest($className)) {
356
                    continue;
357
                }
358
359
                $array[$className] = [];
360
                $fields = $this->getAllValidFields($className);
361
                $filterAny = [];
362
                foreach ($fields as $field) {
363
                    if (! $this->includeFieldTest($className, $field)) {
364
                        continue;
365
                    }
366
                    $filterAny[$field . ':PartialMatch'] = $this->words;
367
                    if ($this->debug) {
368
                        DB::alteration_message(' ... ... Searching in ' . $className . '.' . $field);
369
                    }
370
                }
371
                if ([] !== $filterAny) {
372
                    if ($this->debug) {
373
                        $startInner = microtime(true);
374
                        DB::alteration_message(' ... Filter: ' . implode(', ', array_keys($filterAny)));
375
                    }
376
                    $defaultList = $this->getDefaultList($className);
377
                    if ($defaultList === []) {
378
                        $array[$className] = $className::get();
379
                    }
380
                    $array[$className] = $array[$className]->filter(['ClassName' => $className]);
381
                    $array[$className] = $array[$className]
382
                        ->filterAny($filterAny)
383
                        ->limit($this->Config()->get('limit_of_count_per_data_object'))
384
                        ->columnUnique('ID');
385
                    if ($this->debug) {
386
                        $elaps = microtime(true) - $startInner;
387
                        DB::alteration_message('search for ' . $className . ' taken : ' . $elaps);
388
                    }
389
                }
390
391
                if ($this->debug) {
392
                    DB::alteration_message(' ... No fields in ' . $className);
393
                }
394
            }
395
        } else {
396
            $array = $this->getDefaultResults();
397
        }
398
399
        if ($this->debug) {
400
            $elaps = microtime(true) - $startOuter;
401
            DB::alteration_message('seconds taken find results: ' . $elaps);
402
        }
403
404
        return $array;
405
    }
406
407
    protected function getDefaultResults(): array
408
    {
409
        $back = $this->config()->get('hours_back_for_recent') ?: 24;
410
        $limit = $this->Config()->get('limit_per_class_for_recent') ?: 5;
411
        $threshold = strtotime('-' . $back . ' hours', DBDatetime::now()->getTimestamp());
412
        if (! $threshold) {
413
            $threshold = time() - 86400;
414
        }
415
416
        $array = [];
417
        $classNames = $this->getAllDataObjects();
418
        foreach ($classNames as $className) {
419
            if ($this->includeClassTest($className)) {
420
                $array[$className] = $className::get()
421
                    ->filter('LastEdited:GreaterThan', date('Y-m-d H:i:s', $threshold))
422
                    ->sort(['LastEdited' => 'DESC'])
423
                    ->limit($limit)
424
                    ->column('ID');
425
            }
426
        }
427
428
        return $array;
429
    }
430
431
    /**
432
     * weeds out doubles
433
     */
434
    protected function turnArrayIntoObjects(array $matches, ?int $limit = 0): array
435
    {
436
        $start = 0;
437
        $fullListCheck = [];
438
439
        if (empty($this->objects)) {
440
            if ($limit === null || $limit === 0) {
441
                $limit = (int) $this->Config()->get('limit_of_count_per_data_object');
442
            }
443
444
            $this->objects = [];
445
            if ($this->debug) {
446
                DB::alteration_message('number of classes: ' . count($matches));
447
            }
448
449
            foreach ($matches as $className => $ids) {
450
                if ($this->debug) {
451
                    $start = microtime(true);
452
                    DB::alteration_message(' ... number of matches for : ' . $className . ': ' . count($ids));
453
                }
454
455
                if (count($ids) > 0) {
456
                    $className = (string) $className;
457
                    $items = $className::get()
458
                        ->filter(['ID' => $ids, 'ClassName' => $className])
459
                        ->limit($limit);
460
                    foreach ($items as $item) {
461
                        if (isset($fullListCheck[$item->ClassName][$item->ID])) {
462
                            continue;
463
                        }
464
                        if ($item->canView()) {
465
                            $fullListCheck[$item->ClassName][$item->ID] = true;
466
                            $this->objects[] = $item;
467
                        } else {
468
                            $fullListCheck[$item->ClassName][$item->ID] = false;
469
                        }
470
                    }
471
                }
472
473
                if ($this->debug) {
474
                    $elaps = microtime(true) - $start;
475
                    DB::alteration_message('seconds taken to find objects in: ' . $className . ': ' . $elaps);
476
                }
477
            }
478
        }
479
480
        return $this->objects;
481
    }
482
483
    protected function turnMatchesIntoList(array $matches): ArrayList
484
    {
485
        // helper
486
        //return values
487
        $list = ArrayList::create();
488
        $finder = Injector::inst()->get(FindEditableObjects::class);
489
        $finder->initCache(md5(serialize($this->excludedClassesWithSubClassess)))
490
            ->setExcludedClasses($this->excludedClassesWithSubClassess);
491
492
        $items = $this->turnArrayIntoObjects($matches);
493
        foreach ($items as $item) {
494
            if ($item->canView()) {
495
                $link = $finder->getLink($item);
496
                $cmsEditLink = trim($item->canEdit() ? $finder->getCMSEditLink($item) : '');
497
                $list->push(
498
                    ArrayData::create(
499
                        [
500
                            'HasLink' => (bool) $link,
501
                            'HasCMSEditLink' => (bool) $cmsEditLink,
502
                            'Link' => $link,
503
                            'CMSEditLink' => $cmsEditLink,
504
                            'ID' => $item->ID,
505
                            'LastEdited' => $item->LastEdited,
506
                            'Title' => $item->getTitle(),
507
                            'ClassName' => $item->ClassName,
508
                            'SingularName' => $item->i18n_singular_name(),
509
                            'SiteWideSearchSortValue' => $this->getSortValue($item),
510
                            'CMSThumbnail' => DBField::create_field('HTMLText', $finder->getCMSThumbnail($item)),
511
                        ]
512
                    )
513
                );
514
            }
515
        }
516
        $finder->saveCache();
517
518
        if (! empty($this->sortOverride)) {
519
            return $list->sort($this->sortOverride);
520
        } else {
521
            return $list->sort(['SiteWideSearchSortValue' => 'ASC']);
522
        }
523
    }
524
525
    protected function getSortValue($item)
526
    {
527
        $className = $item->ClassName;
528
        $fields = $this->getAllValidFields($className);
529
        $fullWords = implode(' ', $this->words);
530
531
        $done = false;
532
        $score = 0;
533
        if ($fullWords !== '' && $fullWords !== '0') {
534
            $fieldValues = [];
535
            $fieldValuesAll = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $fieldValuesAll is dead and can be removed.
Loading history...
536
            foreach ($fields as $field) {
537
                $fieldValues[$field] = strtolower(strip_tags((string) $item->{$field}));
538
            }
539
540
            $fieldValuesAll = implode(' ', $fieldValues);
541
            $testWords = array_merge(
542
                [$fullWords],
543
                $this->words
544
            );
545
            $testWords = array_unique($testWords);
546
            foreach ($testWords as $wordKey => $word) {
547
                //match a exact field to full words / one word
548
                $fullWords = ! (bool) $wordKey;
549
                if (false === $done) {
550
                    $count = 0;
551
                    foreach ($fieldValues as $fieldValue) {
552
                        ++$count;
553
                        if ($fieldValue === $word) {
554
                            $score += (int) $wordKey + $count;
555
                            $done = true;
556
557
                            break;
558
                        }
559
                    }
560
                }
561
562
                // the full string / any of the words are present?
563
                if (false === $done) {
564
                    $pos = strpos($fieldValuesAll, $word);
565
                    if (false !== $pos) {
566
                        $score += (($pos + 1) / strlen($word)) * 1000;
567
                        $done = true;
568
                    }
569
                }
570
571
                // all individual words are present
572
                if (false === $done && $fullWords) {
573
                    $score += 1000;
574
                    $allMatch = true;
575
                    foreach ($this->words as $tmpWord) {
576
                        $pos = strpos($fieldValuesAll, $tmpWord);
577
                        if (false === $pos) {
578
                            $allMatch = false;
579
580
                            break;
581
                        }
582
                    }
583
                    if ($allMatch) {
584
                        $done = true;
585
                    }
586
                }
587
            }
588
        }
589
590
        //the older the item, the higher the scoare
591
        //1104622247 = 1 jan 2005
592
        return $score + (1 / (strtotime($item->LastEdited) - 1104537600));
593
    }
594
595
    protected function workOutInclusionsAndExclusions()
596
    {
597
        $this->excludedClasses = array_unique(
598
            array_merge(
599
                $this->Config()->get('default_exclude_classes'),
600
                $this->excludedClasses
601
            )
602
        );
603
        $this->excludedClassesWithSubClassess = $this->includeSubClasses($this->excludedClasses);
604
        $this->includedClasses = array_unique(
605
            array_merge(
606
                $this->Config()->get('default_include_classes'),
607
                $this->includedClasses
608
            )
609
        );
610
        $this->includedClassesWithSubClassess = $this->includeSubClasses($this->includedClasses);
611
        $this->excludedFields = array_unique(
612
            array_merge(
613
                $this->Config()->get('default_exclude_fields'),
614
                $this->excludedFields
615
            )
616
        );
617
618
        $this->includedFields = array_unique(
619
            array_merge(
620
                $this->Config()->get('default_include_fields'),
621
                $this->includedFields
622
            )
623
        );
624
        $this->includedClassFieldCombos = array_unique(
625
            array_merge(
626
                $this->Config()->get('default_include_class_field_combos'),
627
                $this->includedClassFieldCombos
628
            )
629
        );
630
        $this->defaultLists = array_unique(
631
            array_merge(
632
                $this->Config()->get('default_lists'),
633
                $this->defaultLists
634
            )
635
        );
636
    }
637
638
    protected function workOutWordsForSearching()
639
    {
640
        if ($this->searchWholePhrase) {
641
            $this->words = [implode(' ', $this->words)];
642
        }
643
644
        if (count($this->words) === 0) {
645
            user_error('No word has been provided');
646
        }
647
648
        $this->words = array_map('trim', $this->words);
649
        $this->words = array_map('strtolower', $this->words);
650
        $this->words = array_unique($this->words);
651
        $this->words = array_filter($this->words);
652
    }
653
654
    protected function getAllDataObjects(): array
655
    {
656
        return $this->getCache()->getAllDataObjects();
657
    }
658
659
    protected function getAllValidFields(string $className): array
660
    {
661
        return $this->getCache()->getAllValidFields($className, $this->isQuickSearch, $this->includedFields, $this->includedClassFieldCombos);
662
    }
663
664
    protected function includeClassTest(string $className): bool
665
    {
666
        if (count($this->includedClassesWithSubClassess) && ! in_array($className, $this->includedClassesWithSubClassess, true)) {
667
            if ($this->debug) {
668
                DB::alteration_message(' ... Skipping as not included ' . $className);
669
            }
670
            return false;
671
        }
672
        if (count($this->excludedClassesWithSubClassess) && in_array($className, $this->excludedClassesWithSubClassess, true)) {
673
            if ($this->debug) {
674
                DB::alteration_message(' ... Skipping as excluded ' . $className);
675
            }
676
            return false;
677
        }
678
        if ($this->debug) {
679
            DB::alteration_message(' ... including ' . $className);
680
        }
681
682
        return true;
683
    }
684
685
    protected function includeFieldTest(string $className, string $field): bool
686
    {
687
        if (isset($this->includedClassFieldCombos[$className][$field])) {
688
            return true;
689
        } elseif (count($this->includedFields) > 0) {
690
            return in_array($field, $this->includedFields, true);
691
        } elseif (count($this->excludedFields) > 0) {
692
            return ! in_array($field, $this->includedFields, true);
693
        } else {
694
            return false;
695
        }
696
    }
697
698
    protected function includeSubClasses(array $classes): array
699
    {
700
        $toAdd = [];
701
        foreach ($classes as $class) {
702
            $toAdd = array_merge($toAdd, ClassInfo::subclassesFor($class, false));
703
        }
704
        return array_unique(array_merge($classes, $toAdd));
705
    }
706
707
    protected function securityCheckInput(string $word): string
708
    {
709
        $word = trim($word);
710
        return Convert::raw2sql($word);
711
    }
712
713
    protected function getDefaultList(string $className): array
714
    {
715
        return $this->defaultLists[$className] ?? [];
716
    }
717
}
718