1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2020 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Module; 21 22use Fisharebest\Webtrees\Exceptions\HttpNotFoundException; 23use Fisharebest\Webtrees\Family; 24use Fisharebest\Webtrees\GedcomRecord; 25use Fisharebest\Webtrees\I18N; 26use Fisharebest\Webtrees\Individual; 27use Fisharebest\Webtrees\Media; 28use Fisharebest\Webtrees\Note; 29use Fisharebest\Webtrees\Repository; 30use Fisharebest\Webtrees\Services\DataFixService; 31use Fisharebest\Webtrees\Source; 32use Fisharebest\Webtrees\Submitter; 33use Fisharebest\Webtrees\Tree; 34use Illuminate\Database\Capsule\Manager as DB; 35use Illuminate\Database\Query\Builder; 36use Illuminate\Support\Collection; 37use Throwable; 38 39use function addcslashes; 40use function asort; 41use function preg_match; 42use function preg_quote; 43use function preg_replace; 44use function view; 45 46/** 47 * Class FixSearchAndReplace 48 */ 49class FixSearchAndReplace extends AbstractModule implements ModuleDataFixInterface 50{ 51 use ModuleDataFixTrait; 52 53 // A regular expression that never matches. 54 private const INVALID_REGEX = '/(?!)/'; 55 56 /** @var DataFixService */ 57 private $data_fix_service; 58 59 /** 60 * FixMissingDeaths constructor. 61 * 62 * @param DataFixService $data_fix_service 63 */ 64 public function __construct(DataFixService $data_fix_service) 65 { 66 $this->data_fix_service = $data_fix_service; 67 } 68 69 /** 70 * How should this module be identified in the control panel, etc.? 71 * 72 * @return string 73 */ 74 public function title(): string 75 { 76 /* I18N: Name of a module */ 77 return I18N::translate('Search and replace'); 78 } 79 80 /** 81 * A sentence describing what this module does. 82 * 83 * @return string 84 */ 85 public function description(): string 86 { 87 /* I18N: Description of a “Data fix” module */ 88 return I18N::translate('Search and replace text, using simple searches or advanced pattern matching.'); 89 } 90 91 /** 92 * Options form. 93 * 94 * @param Tree $tree 95 * 96 * @return string 97 */ 98 public function fixOptions(Tree $tree): string 99 { 100 $methods = [ 101 'exact' => I18N::translate('Match the exact text, even if it occurs in the middle of a word.'), 102 'words' => I18N::translate('Match the exact text, unless it occurs in the middle of a word.'), 103 'wildcards' => I18N::translate('Use a “?” to match a single character, use “*” to match zero or more characters.'), 104 /* I18N: http://en.wikipedia.org/wiki/Regular_expression */ 105 'regex' => I18N::translate('Regular expression'), 106 ]; 107 108 $types = [ 109 Family::RECORD_TYPE => I18N::translate('Families'), 110 Individual::RECORD_TYPE => I18N::translate('Individuals'), 111 Media::RECORD_TYPE => I18N::translate('Media objects'), 112 Note::RECORD_TYPE => I18N::translate('Notes'), 113 Repository::RECORD_TYPE => I18N::translate('Repositories'), 114 Source::RECORD_TYPE => I18N::translate('Sources'), 115 Submitter::RECORD_TYPE => I18N::translate('Submitters'), 116 ]; 117 118 asort($types); 119 120 return view('modules/fix-search-and-replace/options', [ 121 'default_method' => 'exact', 122 'default_type' => Individual::RECORD_TYPE, 123 'methods' => $methods, 124 'types' => $types, 125 ]); 126 } 127 128 /** 129 * A list of all records that need examining. This may include records 130 * that do not need updating, if we can't detect this quickly using SQL. 131 * 132 * @param Tree $tree 133 * @param array<string,string> $params 134 * 135 * @return Collection<string>|null 136 */ 137 protected function familiesToFix(Tree $tree, array $params): ?Collection 138 { 139 if ($params['type'] !== Family::RECORD_TYPE || $params['search'] === '') { 140 return null; 141 } 142 143 $query = DB::table('families')->where('f_file', '=', $tree->id()); 144 $this->recordQuery($query, 'f_gedcom', $params); 145 146 return $query->pluck('f_id'); 147 } 148 149 /** 150 * A list of all records that need examining. This may include records 151 * that do not need updating, if we can't detect this quickly using SQL. 152 * 153 * @param Tree $tree 154 * @param array<string,string> $params 155 * 156 * @return Collection<string>|null 157 */ 158 protected function individualsToFix(Tree $tree, array $params): ?Collection 159 { 160 if ($params['type'] !== Individual::RECORD_TYPE || $params['search'] === '') { 161 return null; 162 } 163 164 $query = DB::table('individuals') 165 ->where('i_file', '=', $tree->id()); 166 167 $this->recordQuery($query, 'i_gedcom', $params); 168 169 return $query->pluck('i_id'); 170 } 171 172 /** 173 * A list of all records that need examining. This may include records 174 * that do not need updating, if we can't detect this quickly using SQL. 175 * 176 * @param Tree $tree 177 * @param array<string,string> $params 178 * 179 * @return Collection<string>|null 180 */ 181 protected function mediaToFix(Tree $tree, array $params): ?Collection 182 { 183 if ($params['type'] !== Media::RECORD_TYPE || $params['search'] === '') { 184 return null; 185 } 186 187 $query = DB::table('media') 188 ->where('m_file', '=', $tree->id()); 189 190 $this->recordQuery($query, 'm_gedcom', $params); 191 192 return $query->pluck('m_id'); 193 } 194 195 /** 196 * A list of all records that need examining. This may include records 197 * that do not need updating, if we can't detect this quickly using SQL. 198 * 199 * @param Tree $tree 200 * @param array<string,string> $params 201 * 202 * @return Collection<string>|null 203 */ 204 protected function notesToFix(Tree $tree, array $params): ?Collection 205 { 206 if ($params['type'] !== Note::RECORD_TYPE || $params['search'] === '') { 207 return null; 208 } 209 210 $query = DB::table('other') 211 ->where('o_file', '=', $tree->id()) 212 ->where('o_type', '=', Note::RECORD_TYPE); 213 214 $this->recordQuery($query, 'o_gedcom', $params); 215 216 return $query->pluck('o_id'); 217 } 218 219 /** 220 * A list of all records that need examining. This may include records 221 * that do not need updating, if we can't detect this quickly using SQL. 222 * 223 * @param Tree $tree 224 * @param array<string,string> $params 225 * 226 * @return Collection<string>|null 227 */ 228 protected function repositoriesToFix(Tree $tree, array $params): ?Collection 229 { 230 if ($params['type'] !== Repository::RECORD_TYPE || $params['search'] === '') { 231 return null; 232 } 233 234 $query = DB::table('other') 235 ->where('o_file', '=', $tree->id()) 236 ->where('o_type', '=', Repository::RECORD_TYPE); 237 238 $this->recordQuery($query, 'o_gedcom', $params); 239 240 return $query->pluck('o_id'); 241 } 242 243 /** 244 * A list of all records that need examining. This may include records 245 * that do not need updating, if we can't detect this quickly using SQL. 246 * 247 * @param Tree $tree 248 * @param array<string,string> $params 249 * 250 * @return Collection<string>|null 251 */ 252 protected function sourcesToFix(Tree $tree, array $params): ?Collection 253 { 254 if ($params['type'] !== Source::RECORD_TYPE || $params['search'] === '') { 255 return null; 256 } 257 258 $query = $this->sourcesToFixQuery($tree, $params); 259 260 $this->recordQuery($query, 's_gedcom', $params); 261 262 return $query->pluck('s_id'); 263 } 264 265 /** 266 * A list of all records that need examining. This may include records 267 * that do not need updating, if we can't detect this quickly using SQL. 268 * 269 * @param Tree $tree 270 * @param array<string,string> $params 271 * 272 * @return Collection<string>|null 273 */ 274 protected function submittersToFix(Tree $tree, array $params): ?Collection 275 { 276 if ($params['type'] !== Submitter::RECORD_TYPE || $params['search'] === '') { 277 return null; 278 } 279 280 $query = $this->submittersToFixQuery($tree, $params); 281 282 $this->recordQuery($query, 'o_gedcom', $params); 283 284 return $query->pluck('o_id'); 285 } 286 287 /** 288 * Does a record need updating? 289 * 290 * @param GedcomRecord $record 291 * @param array<string,string> $params 292 * 293 * @return bool 294 */ 295 public function doesRecordNeedUpdate(GedcomRecord $record, array $params): bool 296 { 297 return preg_match($this->createRegex($params), $record->gedcom()) === 1; 298 } 299 300 /** 301 * Show the changes we would make 302 * 303 * @param GedcomRecord $record 304 * @param array<string,string> $params 305 * 306 * @return string 307 */ 308 public function previewUpdate(GedcomRecord $record, array $params): string 309 { 310 $old = $record->gedcom(); 311 $new = $this->updateGedcom($record, $params); 312 313 return $this->data_fix_service->gedcomDiff($record->tree(), $old, $new); 314 } 315 316 /** 317 * Fix a record 318 * 319 * @param GedcomRecord $record 320 * @param array<string,string> $params 321 * 322 * @return void 323 */ 324 public function updateRecord(GedcomRecord $record, array $params): void 325 { 326 $record->updateRecord($this->updateGedcom($record, $params), false); 327 } 328 329 /** 330 * @param GedcomRecord $record 331 * @param array<string,string> $params 332 * 333 * @return string 334 */ 335 private function updateGedcom(GedcomRecord $record, array $params): string 336 { 337 // Allow "\n" to indicate a line-feed in replacement text. 338 // Back-references such as $1, $2 are handled automatically. 339 $replace = strtr($params['replace'], ['\n' => "\n"]); 340 341 $regex = $this->createRegex($params); 342 343 return preg_replace($regex, $replace, $record->gedcom()); 344 } 345 346 /** 347 * Create a regular expression from the search pattern. 348 * 349 * @param array<string,string> $params 350 * 351 * @return string 352 */ 353 private function createRegex(array $params): string 354 { 355 $search = $params['search']; 356 $method = $params['method']; 357 $case = $params['case']; 358 359 switch ($method) { 360 case 'exact': 361 return '/' . preg_quote($search, '/') . '/u' . $case; 362 363 case 'words': 364 return '/\b' . preg_quote($search, '/') . '\b/u' . $case; 365 366 case 'wildcards': 367 return '/\b' . strtr(preg_quote($search, '/'), ['\*' => '.*', '\?' => '.']) . '\b/u' . $case; 368 369 case 'regex': 370 $regex = '/' . addcslashes($search, '/') . '/u' . $case; 371 372 try { 373 // A valid regex on an empty string returns zero. 374 // An invalid regex on an empty string returns false and throws a warning. 375 preg_match($regex, ''); 376 } catch (Throwable $ex) { 377 $regex = self::INVALID_REGEX; 378 } 379 380 return $regex; 381 } 382 383 throw new HttpNotFoundException(); 384 } 385 386 /** 387 * Create a regular expression from the search pattern. 388 * 389 * @param Builder $query 390 * @param string $column 391 * @param array<string,string> $params 392 * 393 * @return void 394 */ 395 private function recordQuery(Builder $query, string $column, array $params): void 396 { 397 $search = $params['search']; 398 $method = $params['method']; 399 $like = '%' . addcslashes($search, '\\%_') . '%'; 400 401 switch ($method) { 402 case 'exact': 403 case 'words': 404 $query->where($column, 'LIKE', $like); 405 break; 406 407 case 'wildcards': 408 $like = strtr($like, ['?' => '_', '*' => '%']); 409 $query->where($column, 'LIKE', $like); 410 break; 411 412 case 'regex': 413 // Substituting newlines seems to be necessary on *some* versions 414 //.of MySQL (e.g. 5.7), and harmless on others (e.g. 8.0). 415 $search = strtr($search, ['\n' => "\n"]); 416 417 switch (DB::connection()->getDriverName()) { 418 case 'sqlite': 419 case 'mysql': 420 $query->where($column, 'REGEXP', $search); 421 break; 422 423 case 'pgsql': 424 $query->where($column, '~', $search); 425 break; 426 427 case 'sqlsvr': 428 // Not available 429 break; 430 } 431 break; 432 } 433 } 434} 435