1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2019 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16namespace Fisharebest\Webtrees\Controller;
17
18use Fisharebest\Webtrees\Auth;
19use Fisharebest\Webtrees\Config;
20use Fisharebest\Webtrees\Family;
21use Fisharebest\Webtrees\Filter;
22use Fisharebest\Webtrees\FlashMessages;
23use Fisharebest\Webtrees\Functions\FunctionsDb;
24use Fisharebest\Webtrees\Functions\FunctionsPrintLists;
25use Fisharebest\Webtrees\GedcomRecord;
26use Fisharebest\Webtrees\I18N;
27use Fisharebest\Webtrees\Individual;
28use Fisharebest\Webtrees\Log;
29use Fisharebest\Webtrees\Note;
30use Fisharebest\Webtrees\Site;
31use Fisharebest\Webtrees\Source;
32use Fisharebest\Webtrees\Tree;
33
34/**
35 * Controller for the search page
36 */
37class SearchController extends PageController
38{
39    /** @var string The type of search to perform */
40    public $action;
41
42    /** @var string "checked" if we are to search individuals, empty otherwise */
43    public $srindi;
44
45    /** @var string "checked" if we are to search families, empty otherwise */
46    public $srfams;
47
48    /** @var string "checked" if we are to search sources, empty otherwise */
49    public $srsour;
50
51    /** @var string "checked" if we are to search notes, empty otherwise */
52    public $srnote;
53
54    /** @var Tree[] A list of trees to search */
55    public $search_trees = array();
56
57    /** @var Individual[] Individual search results */
58    protected $myindilist = array();
59
60    /** @var Source[] Source search results */
61    protected $mysourcelist = array();
62
63    /** @var Family[] Family search results */
64    protected $myfamlist = array();
65
66    /** @var Note[] Note search results */
67    protected $mynotelist = array();
68
69    /** @var string The search term(s) */
70    public $query;
71
72    /** @var string The soundex algorithm to use */
73    public $soundex;
74
75    /** @var string @var string Search parameter */
76    public $showasso = 'off';
77
78    /** @var string @var string Search parameter */
79    public $firstname;
80
81    /** @var string @var string Search parameter */
82    public $lastname;
83
84    /** @var string @var string Search parameter */
85    public $place;
86
87    /** @var string @var string Search parameter */
88    public $year;
89
90    /** @var string @var string Replace parameter */
91    public $replace = '';
92
93    /** @var bool @var string Replace parameter */
94    public $replaceNames = false;
95
96    /** @var bool @var string Replace parameter */
97    public $replacePlaces = false;
98
99    /** @var bool @var string Replace parameter */
100    public $replaceAll = false;
101
102    /** @var bool @var string Replace parameter */
103    public $replacePlacesWord = false;
104
105    /**
106     * Startup activity
107     */
108    public function __construct()
109    {
110        global $WT_TREE;
111
112        parent::__construct();
113
114        // $action comes from GET (search) or POST (replace)
115        if (Filter::post('action')) {
116            $this->action            = Filter::post('action', 'replace', 'general');
117            $this->query             = Filter::post('query');
118            $this->replace           = Filter::post('replace');
119            $this->replaceNames      = Filter::post('replaceNames', 'checked', '');
120            $this->replacePlaces     = Filter::post('replacePlaces', 'checked', '');
121            $this->replacePlacesWord = Filter::post('replacePlacesWord', 'checked', '');
122            $this->replaceAll        = Filter::post('replaceAll', 'checked', '');
123        } else {
124            $this->action            = Filter::get('action', 'advanced|general|soundex|replace|header', 'general');
125            $this->query             = Filter::get('query');
126            $this->replace           = Filter::get('replace');
127            $this->replaceNames      = Filter::get('replaceNames', 'checked', '');
128            $this->replacePlaces     = Filter::get('replacePlaces', 'checked', '');
129            $this->replacePlacesWord = Filter::get('replacePlacesWord', 'checked', '');
130            $this->replaceAll        = Filter::get('replaceAll', 'checked', '');
131        }
132
133        // Only editors can use search/replace
134        if ($this->action === 'replace' && !Auth::isEditor($WT_TREE)) {
135            $this->action = 'general';
136        }
137
138        $this->srindi            = Filter::get('srindi', 'checked', '');
139        $this->srfams            = Filter::get('srfams', 'checked', '');
140        $this->srsour            = Filter::get('srsour', 'checked', '');
141        $this->srnote            = Filter::get('srnote', 'checked', '');
142        $this->soundex           = Filter::get('soundex', 'DaitchM|Russell', 'DaitchM');
143        $this->showasso          = Filter::get('showasso');
144        $this->firstname         = Filter::get('firstname');
145        $this->lastname          = Filter::get('lastname');
146        $this->place             = Filter::get('place');
147        $this->year              = Filter::get('year');
148
149        // If no record types specified, search individuals
150        if (!$this->srfams && !$this->srsour && !$this->srnote) {
151            $this->srindi = 'checked';
152        }
153
154        // If no replace types specifiied, replace full records
155        if (!$this->replaceNames && !$this->replacePlaces && !$this->replacePlacesWord) {
156            $this->replaceAll = 'checked';
157        }
158
159        // Trees to search
160        if (Site::getPreference('ALLOW_CHANGE_GEDCOM')) {
161            foreach (Tree::getAll() as $search_tree) {
162                if (Filter::get('tree_' . $search_tree->getTreeId())) {
163                    $this->search_trees[] = $search_tree;
164                }
165            }
166            if (!$this->search_trees) {
167                $this->search_trees[] = $WT_TREE;
168            }
169        } else {
170            $this->search_trees[] = $WT_TREE;
171        }
172
173        // If we want to show associated persons, build the list
174        switch ($this->action) {
175            case 'header':
176                // We can type in an XREF into the header search, and jump straight to it.
177                // Otherwise, the header search is the same as the general search
178                if (preg_match('/' . WT_REGEX_XREF . '/', $this->query)) {
179                    $record = GedcomRecord::getInstance($this->query, $WT_TREE);
180                    if ($record && $record->canShowName()) {
181                        header('Location: ' . WT_BASE_URL . $record->getRawUrl());
182                        exit;
183                    }
184                }
185                $this->action = 'general';
186                $this->srindi = 'checked';
187                $this->srfams = 'checked';
188                $this->srsour = 'checked';
189                $this->srnote = 'checked';
190                $this->setPageTitle(I18N::translate('General search'));
191                $this->generalSearch();
192                break;
193            case 'general':
194                $this->setPageTitle(I18N::translate('General search'));
195                $this->generalSearch();
196                break;
197            case 'soundex':
198                // Create a dummy search query to use as a title to the results list
199                $this->query = trim($this->firstname . ' ' . $this->lastname . ' ' . $this->place);
200                $this->setPageTitle(I18N::translate('Phonetic search'));
201                $this->soundexSearch();
202                break;
203            case 'replace':
204                $this->setPageTitle(I18N::translate('Search and replace'));
205                $this->search_trees = array($WT_TREE);
206                $this->srindi       = 'checked';
207                $this->srfams       = 'checked';
208                $this->srsour       = 'checked';
209                $this->srnote       = 'checked';
210                if (Filter::post('query')) {
211                    $this->searchAndReplace($WT_TREE);
212                    header('Location: ' . WT_BASE_URL . WT_SCRIPT_NAME . '?action=replace&query=' . Filter::escapeUrl($this->query) . '&replace=' . Filter::escapeUrl($this->replace) . '&replaceAll=' . $this->replaceAll . '&replaceNames=' . $this->replaceNames . '&replacePlaces=' . $this->replacePlaces . '&replacePlacesWord=' . $this->replacePlacesWord);
213                    exit;
214                }
215        }
216    }
217
218    /**
219     * Gathers results for a general search
220     */
221    private function generalSearch()
222    {
223        // Split search terms into an array
224        $query_terms = array();
225        $query       = $this->query;
226        // Words in double quotes stay together
227        while (preg_match('/"([^"]+)"/', $query, $match)) {
228            $query_terms[] = trim($match[1]);
229            $query         = str_replace($match[0], '', $query);
230        }
231        // Other words get treated separately
232        while (preg_match('/[\S]+/', $query, $match)) {
233            $query_terms[] = trim($match[0]);
234            $query         = str_replace($match[0], '', $query);
235        }
236
237        //-- perform the search
238        if ($query_terms && $this->search_trees) {
239            // Write a log entry
240            $logstring = "Type: General\nQuery: " . $this->query;
241            Log::addSearchLog($logstring, $this->search_trees);
242
243            // Search the individuals
244            if ($this->srindi && $query_terms) {
245                $this->myindilist = FunctionsDb::searchIndividuals($query_terms, $this->search_trees);
246            }
247
248            // Search the fams
249            if ($this->srfams && $query_terms) {
250                $this->myfamlist = array_merge(
251                    FunctionsDb::searchFamilies($query_terms, $this->search_trees),
252                    FunctionsDb::searchFamilyNames($query_terms, $this->search_trees)
253                );
254                $this->myfamlist = array_unique($this->myfamlist);
255            }
256
257            // Search the sources
258            if ($this->srsour && $query_terms) {
259                $this->mysourcelist = FunctionsDb::searchSources($query_terms, $this->search_trees);
260            }
261
262            // Search the notes
263            if ($this->srnote && $query_terms) {
264                $this->mynotelist = FunctionsDb::searchNotes($query_terms, $this->search_trees);
265            }
266
267            if ($this->action === 'general') {
268                // If only 1 item is returned, automatically forward to that item
269                // If ID cannot be displayed, continue to the search page.
270                if (count($this->myindilist) == 1 && !$this->myfamlist && !$this->mysourcelist && !$this->mynotelist) {
271                    $indi = reset($this->myindilist);
272                    if ($indi->canShowName()) {
273                        header('Location: ' . WT_BASE_URL . $indi->getRawUrl());
274                        exit;
275                    }
276                }
277                if (!$this->myindilist && count($this->myfamlist) == 1 && !$this->mysourcelist && !$this->mynotelist) {
278                    $fam = reset($this->myfamlist);
279                    if ($fam->canShowName()) {
280                        header('Location: ' . WT_BASE_URL . $fam->getRawUrl());
281                        exit;
282                    }
283                }
284                if (!$this->myindilist && !$this->myfamlist && count($this->mysourcelist) == 1 && !$this->mynotelist) {
285                    $sour = reset($this->mysourcelist);
286                    if ($sour->canShowName()) {
287                        header('Location: ' . WT_BASE_URL . $sour->getRawUrl());
288                        exit;
289                    }
290                }
291                if (!$this->myindilist && !$this->myfamlist && !$this->mysourcelist && count($this->mynotelist) == 1) {
292                    $note = reset($this->mynotelist);
293                    if ($note->canShowName()) {
294                        header('Location: ' . WT_BASE_URL . $note->getRawUrl());
295                        exit;
296                    }
297                }
298            }
299        }
300    }
301
302    /**
303     * Performs a search and replace
304     *
305     * @param Tree $tree
306     */
307    private function searchAndReplace(Tree $tree)
308    {
309        $this->generalSearch();
310
311        //-- don't try to make any changes if nothing was found
312        if (!$this->myindilist && !$this->myfamlist && !$this->mysourcelist && !$this->mynotelist) {
313            return;
314        }
315
316        Log::addEditLog("Search And Replace old:" . $this->query . " new:" . $this->replace);
317
318        $query = preg_quote($this->query, '/');
319
320        $adv_name_tags   = preg_split("/[\s,;: ]+/", $tree->getPreference('ADVANCED_NAME_FACTS'));
321        $name_tags       = array_unique(array_merge(Config::standardNameFacts(), $adv_name_tags));
322        $name_tags[]     = '_MARNM';
323        $records_updated = 0;
324        foreach ($this->myindilist as $id => $record) {
325            $old_record = $record->getGedcom();
326            $new_record = $old_record;
327            if ($this->replaceAll) {
328                $new_record = preg_replace("/" . $query . "/i", $this->replace, $new_record);
329            } else {
330                if ($this->replaceNames) {
331                    foreach ($name_tags as $tag) {
332                        $new_record = preg_replace("/(\d) " . $tag . " (.*)" . $query . "(.*)/i", "$1 " . $tag . " $2" . $this->replace . "$3", $new_record);
333                    }
334                }
335                if ($this->replacePlaces) {
336                    if ($this->replacePlacesWord) {
337                        $new_record = preg_replace('/(\d) PLAC (.*)([,\W\s])' . $query . '([,\W\s])/i', "$1 PLAC $2$3" . $this->replace . "$4", $new_record);
338                    } else {
339                        $new_record = preg_replace("/(\d) PLAC (.*)" . $query . "(.*)/i", "$1 PLAC $2" . $this->replace . "$3", $new_record);
340                    }
341                }
342            }
343            //-- if the record changed replace the record otherwise remove it from the search results
344            if ($new_record !== $old_record) {
345                $record->updateRecord($new_record, true);
346                $records_updated++;
347            } else {
348                unset($this->myindilist[$id]);
349            }
350        }
351
352        if ($records_updated) {
353            FlashMessages::addMessage(I18N::plural('%s individual has been updated.', '%s individuals have been updated.', $records_updated, I18N::number($records_updated)));
354        }
355
356        $records_updated = 0;
357        foreach ($this->myfamlist as $id => $record) {
358            $old_record = $record->getGedcom();
359            $new_record = $old_record;
360
361            if ($this->replaceAll) {
362                $new_record = preg_replace("/" . $query . "/i", $this->replace, $new_record);
363            } else {
364                if ($this->replacePlaces) {
365                    if ($this->replacePlacesWord) {
366                        $new_record = preg_replace('/(\d) PLAC (.*)([,\W\s])' . $query . '([,\W\s])/i', "$1 PLAC $2$3" . $this->replace . "$4", $new_record);
367                    } else {
368                        $new_record = preg_replace("/(\d) PLAC (.*)" . $query . "(.*)/i", "$1 PLAC $2" . $this->replace . "$3", $new_record);
369                    }
370                }
371            }
372            //-- if the record changed replace the record otherwise remove it from the search results
373            if ($new_record !== $old_record) {
374                $record->updateRecord($new_record, true);
375                $records_updated++;
376            } else {
377                unset($this->myfamlist[$id]);
378            }
379        }
380
381        if ($records_updated) {
382            FlashMessages::addMessage(I18N::plural('%s family has been updated.', '%s families have been updated.', $records_updated, I18N::number($records_updated)));
383        }
384
385        $records_updated = 0;
386        foreach ($this->mysourcelist as $id => $record) {
387            $old_record = $record->getGedcom();
388            $new_record = $old_record;
389
390            if ($this->replaceAll) {
391                $new_record = preg_replace("/" . $query . "/i", $this->replace, $new_record);
392            } else {
393                if ($this->replaceNames) {
394                    $new_record = preg_replace("/(\d) TITL (.*)" . $query . "(.*)/i", "$1 TITL $2" . $this->replace . "$3", $new_record);
395                    $new_record = preg_replace("/(\d) ABBR (.*)" . $query . "(.*)/i", "$1 ABBR $2" . $this->replace . "$3", $new_record);
396                }
397                if ($this->replacePlaces) {
398                    if ($this->replacePlacesWord) {
399                        $new_record = preg_replace('/(\d) PLAC (.*)([,\W\s])' . $query . '([,\W\s])/i', "$1 PLAC $2$3" . $this->replace . "$4", $new_record);
400                    } else {
401                        $new_record = preg_replace("/(\d) PLAC (.*)" . $query . "(.*)/i", "$1 PLAC $2" . $this->replace . "$3", $new_record);
402                    }
403                }
404            }
405            //-- if the record changed replace the record otherwise remove it from the search results
406            if ($new_record !== $old_record) {
407                $record->updateRecord($new_record, true);
408                $records_updated++;
409            } else {
410                unset($this->mysourcelist[$id]);
411            }
412        }
413
414        if ($records_updated) {
415            FlashMessages::addMessage(I18N::plural('%s source has been updated.', '%s sources have been updated.', $records_updated, I18N::number($records_updated)));
416        }
417
418        $records_updated = 0;
419        foreach ($this->mynotelist as $id => $record) {
420            $old_record = $record->getGedcom();
421            $new_record = $old_record;
422
423            if ($this->replaceAll) {
424                $new_record = preg_replace("/" . $query . "/i", $this->replace, $new_record);
425            }
426            //-- if the record changed replace the record otherwise remove it from the search results
427            if ($new_record != $old_record) {
428                $record->updateRecord($new_record, true);
429                $records_updated++;
430            } else {
431                unset($this->mynotelist[$id]);
432            }
433        }
434
435        if ($records_updated) {
436            FlashMessages::addMessage(I18N::plural('%s note has been updated.', '%s notes have been updated.', $records_updated, I18N::number($records_updated)));
437        }
438    }
439
440    /**
441     *  Gathers results for a soundex search
442     *
443     *  NOTE
444     *  ====
445     *  Does not search on the selected gedcoms, searches on all the gedcoms
446     *  Does not work on first names, instead of the code, value array is used in the search
447     *  Returns all the names even when Names with hit selected
448     *  Does not sort results by first name
449     *  Does not work on separate double word surnames
450     *  Does not work on duplicate code values of the searched text and does not give the correct code
451     *     Cohen should give DM codes 556000, 456000, 460000 and 560000, in 4.1 we search only on 560000??
452     *
453     *  The names' Soundex SQL table contains all the soundex values twice
454     *  The places table contains only one value
455     */
456    private function soundexSearch()
457    {
458        if (((!empty($this->lastname)) || (!empty($this->firstname)) || (!empty($this->place))) && $this->search_trees) {
459            $logstring = "Type: Soundex\n";
460            if (!empty($this->lastname)) {
461                $logstring .= "Last name: " . $this->lastname . "\n";
462            }
463            if (!empty($this->firstname)) {
464                $logstring .= "First name: " . $this->firstname . "\n";
465            }
466            if (!empty($this->place)) {
467                $logstring .= "Place: " . $this->place . "\n";
468            }
469            if (!empty($this->year)) {
470                $logstring .= "Year: " . $this->year . "\n";
471            }
472            Log::addSearchLog($logstring, $this->search_trees);
473
474            if ($this->search_trees) {
475                $this->myindilist = FunctionsDb::searchIndividualsPhonetic($this->soundex, $this->lastname, $this->firstname, $this->place, $this->search_trees);
476            } else {
477                $this->myindilist = array();
478            }
479        }
480
481        // Now we have the final list of individuals to be printed.
482        // We may add the assos at this point.
483
484        if ($this->showasso == 'on') {
485            foreach ($this->myindilist as $indi) {
486                foreach ($indi->linkedIndividuals('ASSO') as $asso) {
487                    $this->myindilist[] = $asso;
488                }
489                foreach ($indi->linkedIndividuals('_ASSO') as $asso) {
490                    $this->myindilist[] = $asso;
491                }
492                foreach ($indi->linkedFamilies('ASSO') as $asso) {
493                    $this->myfamlist[] = $asso;
494                }
495                foreach ($indi->linkedFamilies('_ASSO') as $asso) {
496                    $this->myfamlist[] = $asso;
497                }
498            }
499        }
500
501        //-- if only 1 item is returned, automatically forward to that item
502        if (count($this->myindilist) == 1 && $this->action != "replace") {
503            $indi = reset($this->myindilist);
504            header('Location: ' . WT_BASE_URL . $indi->getRawUrl());
505            exit;
506        }
507        usort($this->myindilist, '\Fisharebest\Webtrees\GedcomRecord::compare');
508        usort($this->myfamlist, '\Fisharebest\Webtrees\GedcomRecord::compare');
509    }
510
511    /**
512     * Display the search results
513     */
514    public function printResults()
515    {
516        if ($this->action !== 'replace' && ($this->query || $this->firstname || $this->lastname || $this->place)) {
517            if ($this->myindilist || $this->myfamlist || $this->mysourcelist || $this->mynotelist) {
518                $this->addInlineJavascript('jQuery("#search-result-tabs").tabs();');
519                $this->addInlineJavascript('jQuery("#search-result-tabs").css("visibility", "visible");');
520                $this->addInlineJavascript('jQuery(".loading-image").css("display", "none");');
521                echo '<br>';
522                echo '<div class="loading-image"></div>';
523                echo '<div id="search-result-tabs"><ul>';
524                if (!empty($this->myindilist)) {
525                    echo '<li><a href="#individual-results-tab">', I18N::translate('Individuals'), '</a></li>';
526                }
527                if (!empty($this->myfamlist)) {
528                    echo '<li><a href="#families-results-tab">', I18N::translate('Families'), '</a></li>';
529                }
530                if (!empty($this->mysourcelist)) {
531                    echo '<li><a href="#sources-results-tab">', I18N::translate('Sources'), '</a></li>';
532                }
533                if (!empty($this->mynotelist)) {
534                    echo '<li><a href="#notes-results-tab">', I18N::translate('Notes'), '</a></li>';
535                }
536                echo '</ul>';
537                if (!empty($this->myindilist)) {
538                    echo '<div id="individual-results-tab">', FunctionsPrintLists::individualTable($this->myindilist), '</div>';
539                }
540                if (!empty($this->myfamlist)) {
541                    echo '<div id="families-results-tab">', FunctionsPrintLists::familyTable($this->myfamlist), '</div>';
542                }
543                if (!empty($this->mysourcelist)) {
544                    echo '<div id="sources-results-tab">', FunctionsPrintLists::sourceTable($this->mysourcelist), '</div>';
545                }
546                if (!empty($this->mynotelist)) {
547                    echo '<div id="notes-results-tab">', FunctionsPrintLists::noteTable($this->mynotelist), '</div>';
548                }
549                echo '</div>';
550            } else {
551                // One or more search terms were specified, but no results were found.
552                echo '<div class="warning center">' . I18N::translate('No results found.') . '</div>';
553            }
554        }
555    }
556}
557