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