1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Services; 21 22use Fisharebest\Webtrees\Fact; 23use Fisharebest\Webtrees\Family; 24use Fisharebest\Webtrees\Gedcom; 25use Fisharebest\Webtrees\GedcomRecord; 26use Fisharebest\Webtrees\Individual; 27use Fisharebest\Webtrees\Note; 28use Fisharebest\Webtrees\Registry; 29use Fisharebest\Webtrees\Site; 30use Fisharebest\Webtrees\Tree; 31use Psr\Http\Message\ServerRequestInterface; 32 33use function array_filter; 34use function array_merge; 35use function array_shift; 36use function array_unique; 37use function array_values; 38use function assert; 39use function count; 40use function explode; 41use function implode; 42use function max; 43use function preg_match_all; 44use function preg_replace; 45use function preg_split; 46use function str_repeat; 47use function str_replace; 48use function substr_count; 49use function trim; 50 51use const ARRAY_FILTER_USE_KEY; 52use const PHP_INT_MAX; 53 54/** 55 * Utilities to edit/save GEDCOM data. 56 */ 57class GedcomEditService 58{ 59 /** @var string[] */ 60 public $glevels = []; 61 62 /** @var string[] */ 63 public $tag = []; 64 65 /** @var string[] */ 66 public $islink = []; 67 68 /** @var string[] */ 69 public $text = []; 70 71 /** @var string[] */ 72 protected $glevelsSOUR = []; 73 74 /** @var string[] */ 75 protected $tagSOUR = []; 76 77 /** @var string[] */ 78 protected $islinkSOUR = []; 79 80 /** @var string[] */ 81 protected $textSOUR = []; 82 83 /** @var string[] */ 84 protected $glevelsRest = []; 85 86 /** @var string[] */ 87 protected $tagRest = []; 88 89 /** @var string[] */ 90 protected $islinkRest = []; 91 92 /** @var string[] */ 93 protected $textRest = []; 94 95 /** 96 * This function splits the $glevels, $tag, $islink, and $text arrays so that the 97 * entries associated with a SOUR record are separate from everything else. 98 * 99 * Input arrays: 100 * - $glevels[] - an array of the gedcom level for each line that was edited 101 * - $tag[] - an array of the tags for each gedcom line that was edited 102 * - $islink[] - an array of 1 or 0 values to indicate when the text is a link element 103 * - $text[] - an array of the text data for each line 104 * 105 * Output arrays: 106 * ** For the SOUR record: 107 * - $glevelsSOUR[] - an array of the gedcom level for each line that was edited 108 * - $tagSOUR[] - an array of the tags for each gedcom line that was edited 109 * - $islinkSOUR[] - an array of 1 or 0 values to indicate when the text is a link element 110 * - $textSOUR[] - an array of the text data for each line 111 * ** For the remaining records: 112 * - $glevelsRest[] - an array of the gedcom level for each line that was edited 113 * - $tagRest[] - an array of the tags for each gedcom line that was edited 114 * - $islinkRest[] - an array of 1 or 0 values to indicate when the text is a link element 115 * - $textRest[] - an array of the text data for each line 116 * 117 * @return void 118 */ 119 public function splitSource(): void 120 { 121 $this->glevelsSOUR = []; 122 $this->tagSOUR = []; 123 $this->islinkSOUR = []; 124 $this->textSOUR = []; 125 126 $this->glevelsRest = []; 127 $this->tagRest = []; 128 $this->islinkRest = []; 129 $this->textRest = []; 130 131 $inSOUR = false; 132 $levelSOUR = 0; 133 134 // Assume all arrays are the same size. 135 $count = count($this->glevels); 136 137 for ($i = 0; $i < $count; $i++) { 138 if ($inSOUR) { 139 if ($levelSOUR < $this->glevels[$i]) { 140 $dest = 'S'; 141 } else { 142 $inSOUR = false; 143 $dest = 'R'; 144 } 145 } elseif ($this->tag[$i] === 'SOUR') { 146 $inSOUR = true; 147 $levelSOUR = $this->glevels[$i]; 148 $dest = 'S'; 149 } else { 150 $dest = 'R'; 151 } 152 153 if ($dest === 'S') { 154 $this->glevelsSOUR[] = $this->glevels[$i]; 155 $this->tagSOUR[] = $this->tag[$i]; 156 $this->islinkSOUR[] = $this->islink[$i]; 157 $this->textSOUR[] = $this->text[$i]; 158 } else { 159 $this->glevelsRest[] = $this->glevels[$i]; 160 $this->tagRest[] = $this->tag[$i]; 161 $this->islinkRest[] = $this->islink[$i]; 162 $this->textRest[] = $this->text[$i]; 163 } 164 } 165 } 166 167 /** 168 * Add new GEDCOM lines from the $xxxRest interface update arrays, which 169 * were produced by the splitSOUR() function. 170 * See the FunctionsEdit::handle_updatesges() function for details. 171 * 172 * @param string $inputRec 173 * 174 * @return string 175 */ 176 public function updateRest(string $inputRec): string 177 { 178 if (count($this->tagRest) === 0) { 179 return $inputRec; // No update required 180 } 181 182 // Save original interface update arrays before replacing them with the xxxRest ones 183 $glevelsSave = $this->glevels; 184 $tagSave = $this->tag; 185 $islinkSave = $this->islink; 186 $textSave = $this->text; 187 188 $this->glevels = $this->glevelsRest; 189 $this->tag = $this->tagRest; 190 $this->islink = $this->islinkRest; 191 $this->text = $this->textRest; 192 193 $myRecord = $this->handleUpdates($inputRec, 'no'); // Now do the update 194 195 // Restore the original interface update arrays (just in case ...) 196 $this->glevels = $glevelsSave; 197 $this->tag = $tagSave; 198 $this->islink = $islinkSave; 199 $this->text = $textSave; 200 201 return $myRecord; 202 } 203 204 /** 205 * Add new gedcom lines from interface update arrays 206 * The edit_interface and FunctionsEdit::add_simple_tag function produce the following 207 * arrays incoming from the $_POST form 208 * - $glevels[] - an array of the gedcom level for each line that was edited 209 * - $tag[] - an array of the tags for each gedcom line that was edited 210 * - $islink[] - an array of 1 or 0 values to tell whether the text is a link element and should be surrounded by @@ 211 * - $text[] - an array of the text data for each line 212 * With these arrays you can recreate the gedcom lines like this 213 * <code>$glevel[0].' '.$tag[0].' '.$text[0]</code> 214 * There will be an index in each of these arrays for each line of the gedcom 215 * fact that is being edited. 216 * If the $text[] array is empty for the given line, then it means that the 217 * user removed that line during editing or that the line is supposed to be 218 * empty (1 DEAT, 1 BIRT) for example. To know if the line should be removed 219 * there is a section of code that looks ahead to the next lines to see if there 220 * are sub lines. For example we don't want to remove the 1 DEAT line if it has 221 * a 2 PLAC or 2 DATE line following it. If there are no sub lines, then the line 222 * can be safely removed. 223 * 224 * @param string $newged the new gedcom record to add the lines to 225 * @param string $levelOverride Override GEDCOM level specified in $glevels[0] 226 * 227 * @return string The updated gedcom record 228 */ 229 public function handleUpdates(string $newged, string $levelOverride = 'no'): string 230 { 231 if ($levelOverride === 'no') { 232 $levelAdjust = 0; 233 } else { 234 $levelAdjust = 1; 235 } 236 237 // Assert all arrays are the same size. 238 assert(count($this->glevels) === count($this->tag)); 239 assert(count($this->glevels) === count($this->text)); 240 assert(count($this->glevels) === count($this->islink)); 241 242 $count = count($this->glevels); 243 244 for ($j = 0; $j < $count; $j++) { 245 // Look for empty SOUR reference with non-empty sub-records. 246 // This can happen when the SOUR entry is deleted but its sub-records 247 // were incorrectly left intact. 248 // The sub-records should be deleted. 249 if ($this->tag[$j] === 'SOUR' && ($this->text[$j] === '@@' || $this->text[$j] === '')) { 250 $this->text[$j] = ''; 251 $k = $j + 1; 252 while ($k < $count && $this->glevels[$k] > $this->glevels[$j]) { 253 $this->text[$k] = ''; 254 $k++; 255 } 256 } 257 258 if (trim($this->text[$j]) !== '') { 259 $pass = true; 260 } else { 261 //-- for facts with empty values they must have sub records 262 //-- this section checks if they have subrecords 263 $k = $j + 1; 264 $pass = false; 265 while ($k < $count && $this->glevels[$k] > $this->glevels[$j]) { 266 if ($this->text[$k] !== '') { 267 if ($this->tag[$j] !== 'OBJE' || $this->tag[$k] === 'FILE') { 268 $pass = true; 269 break; 270 } 271 } 272 $k++; 273 } 274 } 275 276 //-- if the value is not empty or it has sub lines 277 //--- then write the line to the gedcom record 278 //-- we have to let some emtpy text lines pass through... (DEAT, BIRT, etc) 279 if ($pass) { 280 $newline = (int) $this->glevels[$j] + $levelAdjust . ' ' . $this->tag[$j]; 281 if ($this->text[$j] !== '') { 282 if ($this->islink[$j]) { 283 $newline .= ' @' . trim($this->text[$j], '@') . '@'; 284 } else { 285 $newline .= ' ' . $this->text[$j]; 286 } 287 } 288 $next_level = 1 + (int) $this->glevels[$j] + $levelAdjust; 289 290 $newged .= "\n" . str_replace("\n", "\n" . $next_level . ' CONT ', $newline); 291 } 292 } 293 294 return $newged; 295 } 296 297 /** 298 * Create a form to add a new fact. 299 * 300 * @param ServerRequestInterface $request 301 * @param Tree $tree 302 * @param string $fact 303 * 304 * @return string 305 */ 306 public function addNewFact(ServerRequestInterface $request, Tree $tree, string $fact): string 307 { 308 $params = (array) $request->getParsedBody(); 309 310 $FACT = $params[$fact]; 311 $DATE = $params[$fact . '_DATE'] ?? ''; 312 $PLAC = $params[$fact . '_PLAC'] ?? ''; 313 314 if ($DATE !== '' || $PLAC !== '' || $FACT !== '' && $FACT !== 'Y') { 315 if ($FACT !== '' && $FACT !== 'Y') { 316 $gedrec = "\n1 " . $fact . ' ' . $FACT; 317 } else { 318 $gedrec = "\n1 " . $fact; 319 } 320 if ($DATE !== '') { 321 $gedrec .= "\n2 DATE " . $DATE; 322 } 323 if ($PLAC !== '') { 324 $gedrec .= "\n2 PLAC " . $PLAC; 325 326 if (preg_match_all('/(' . Gedcom::REGEX_TAG . ')/', $tree->getPreference('ADVANCED_PLAC_FACTS'), $match)) { 327 foreach ($match[1] as $tag) { 328 $TAG = $params[$fact . '_' . $tag]; 329 if ($TAG !== '') { 330 $gedrec .= "\n3 " . $tag . ' ' . $TAG; 331 } 332 } 333 } 334 $LATI = $params[$fact . '_LATI'] ?? ''; 335 $LONG = $params[$fact . '_LONG'] ?? ''; 336 if ($LATI !== '' || $LONG !== '') { 337 $gedrec .= "\n3 MAP\n4 LATI " . $LATI . "\n4 LONG " . $LONG; 338 } 339 } 340 if ((bool) ($params['SOUR_' . $fact] ?? false)) { 341 return $this->updateSource($gedrec, 'yes'); 342 } 343 344 return $gedrec; 345 } 346 347 if ($FACT === 'Y') { 348 if ((bool) ($params['SOUR_' . $fact] ?? false)) { 349 return $this->updateSource("\n1 " . $fact . ' Y', 'yes'); 350 } 351 352 return "\n1 " . $fact . ' Y'; 353 } 354 355 return ''; 356 } 357 358 /** 359 * Add new GEDCOM lines from the $xxxSOUR interface update arrays, which 360 * were produced by the splitSOUR() function. 361 * See the FunctionsEdit::handle_updatesges() function for details. 362 * 363 * @param string $inputRec 364 * @param string $levelOverride 365 * 366 * @return string 367 */ 368 public function updateSource(string $inputRec, string $levelOverride = 'no'): string 369 { 370 if (count($this->tagSOUR) === 0) { 371 return $inputRec; // No update required 372 } 373 374 // Save original interface update arrays before replacing them with the xxxSOUR ones 375 $glevelsSave = $this->glevels; 376 $tagSave = $this->tag; 377 $islinkSave = $this->islink; 378 $textSave = $this->text; 379 380 $this->glevels = $this->glevelsSOUR; 381 $this->tag = $this->tagSOUR; 382 $this->islink = $this->islinkSOUR; 383 $this->text = $this->textSOUR; 384 385 $myRecord = $this->handleUpdates($inputRec, $levelOverride); // Now do the update 386 387 // Restore the original interface update arrays (just in case ...) 388 $this->glevels = $glevelsSave; 389 $this->tag = $tagSave; 390 $this->islink = $islinkSave; 391 $this->text = $textSave; 392 393 return $myRecord; 394 } 395 396 /** 397 * Create a form to add a sex record. 398 * 399 * @param ServerRequestInterface $request 400 * 401 * @return string 402 */ 403 public function addNewSex(ServerRequestInterface $request): string 404 { 405 $params = (array) $request->getParsedBody(); 406 407 switch ($params['SEX']) { 408 case 'M': 409 return "\n1 SEX M"; 410 case 'F': 411 return "\n1 SEX F"; 412 default: 413 return "\n1 SEX U"; 414 } 415 } 416 417 /** 418 * Assemble the pieces of a newly created record into gedcom 419 * 420 * @param ServerRequestInterface $request 421 * @param Tree $tree 422 * 423 * @return string 424 */ 425 public function addNewName(ServerRequestInterface $request, Tree $tree): string 426 { 427 $params = (array) $request->getParsedBody(); 428 $gedrec = "\n1 NAME " . $params['NAME']; 429 430 $tags = [ 431 'NPFX', 432 'GIVN', 433 'SPFX', 434 'SURN', 435 'NSFX', 436 'NICK', 437 ]; 438 439 if (preg_match_all('/(' . Gedcom::REGEX_TAG . ')/', $tree->getPreference('ADVANCED_NAME_FACTS'), $match)) { 440 $tags = array_merge($tags, $match[1]); 441 } 442 443 // Paternal and Polish and Lithuanian surname traditions can also create a _MARNM 444 $SURNAME_TRADITION = $tree->getPreference('SURNAME_TRADITION'); 445 if ($SURNAME_TRADITION === 'paternal' || $SURNAME_TRADITION === 'polish' || $SURNAME_TRADITION === 'lithuanian') { 446 $tags[] = '_MARNM'; 447 } 448 449 foreach (array_unique($tags) as $tag) { 450 $TAG = $params[$tag]; 451 452 if ($TAG !== '') { 453 $gedrec .= "\n2 " . $tag . ' ' . $TAG; 454 } 455 } 456 457 return $gedrec; 458 } 459 460 /** 461 * Reassemble edited GEDCOM fields into a GEDCOM fact/event string. 462 * 463 * @param string $record_type 464 * @param array<string> $levels 465 * @param array<string> $tags 466 * @param array<string> $values 467 * 468 * @return string 469 */ 470 public function editLinesToGedcom(string $record_type, array $levels, array $tags, array $values): string 471 { 472 // Assert all arrays are the same size. 473 $count = count($levels); 474 assert($count > 0); 475 assert(count($tags) === $count); 476 assert(count($values) === $count); 477 478 $gedcom_lines = []; 479 $hierarchy = [$record_type]; 480 481 for ($i = 0; $i < $count; $i++) { 482 $hierarchy[$levels[$i]] = $tags[$i]; 483 484 $full_tag = implode(':', array_slice($hierarchy, 0, 1 + (int) $levels[$i])); 485 $element = Registry::elementFactory()->make($full_tag); 486 $values[$i] = $element->canonical($values[$i]); 487 488 // If "1 FACT Y" has a DATE or PLAC, then delete the value of Y 489 if ($levels[$i] === '1' && $values[$i] === 'Y') { 490 for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; ++$j) { 491 if ($levels[$j] === '2' && ($tags[$j] === 'DATE' || $tags[$j] === 'PLAC') && $values[$j] !== '') { 492 $values[$i] = ''; 493 break; 494 } 495 } 496 } 497 498 // Include this line if there is a value - or if there is a child record with a value. 499 $include = $values[$i] !== ''; 500 501 for ($j = $i + 1; !$include && $j < $count && $levels[$j] > $levels[$i]; $j++) { 502 $include = $values[$j] !== ''; 503 } 504 505 if ($include) { 506 if ($values[$i] === '') { 507 $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i]; 508 } else { 509 if ($tags[$i] === 'CONC') { 510 $next_level = (int) $levels[$i]; 511 } else { 512 $next_level = 1 + (int) $levels[$i]; 513 } 514 515 $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i] . ' ' . str_replace("\n", "\n" . $next_level . ' CONT ', $values[$i]); 516 } 517 } 518 } 519 520 return implode("\n", $gedcom_lines); 521 } 522 523 /** 524 * Add blank lines, to allow a user to add/edit new values. 525 * 526 * @param Fact $fact 527 * @param bool $include_hidden 528 * 529 * @return string 530 */ 531 public function insertMissingFactSubtags(Fact $fact, bool $include_hidden): string 532 { 533 return $this->insertMissingLevels($fact->record()->tree(), $fact->tag(), $fact->gedcom(), $include_hidden); 534 } 535 536 /** 537 * Add blank lines, to allow a user to add/edit new values. 538 * 539 * @param GedcomRecord $record 540 * @param bool $include_hidden 541 * 542 * @return string 543 */ 544 public function insertMissingRecordSubtags(GedcomRecord $record, bool $include_hidden): string 545 { 546 $gedcom = $this->insertMissingLevels($record->tree(), $record->tag(), $record->gedcom(), $include_hidden); 547 548 // NOTE records have data at level 0. Move it to 1 CONC. 549 if ($record instanceof Note) { 550 return preg_replace('/^0 @[^@]+@ NOTE/', '1 CONC', $gedcom); 551 } 552 553 return preg_replace('/^0.*\n/', '', $gedcom); 554 } 555 556 /** 557 * List of facts/events to add to families and individuals. 558 * 559 * @param Family|Individual $record 560 * @param bool $include_hidden 561 * 562 * @return array<string> 563 */ 564 public function factsToAdd(GedcomRecord $record, bool $include_hidden): array 565 { 566 $subtags = Registry::elementFactory()->make($record->tag())->subtags(); 567 568 if (!$include_hidden) { 569 $fn_hidden = fn (string $t): bool => !$this->isHiddenTag($record->tag() . ':' . $t); 570 $subtags = array_filter($subtags, $fn_hidden); 571 } 572 573 return $subtags; 574 } 575 576 /** 577 * @param Tree $tree 578 * @param string $tag 579 * @param string $gedcom 580 * @param bool $include_hidden 581 * 582 * @return string 583 */ 584 protected function insertMissingLevels(Tree $tree, string $tag, string $gedcom, bool $include_hidden): string 585 { 586 $next_level = substr_count($tag, ':') + 1; 587 $factory = Registry::elementFactory(); 588 $subtags = $factory->make($tag)->subtags(); 589 590 // Merge CONT records onto their parent line. 591 $gedcom = strtr($gedcom, [ 592 "\n" . $next_level . ' CONT ' => "\r", 593 "\n" . $next_level . ' CONT' => "\r", 594 ]); 595 596 // The first part is level N. The remainder are level N+1. 597 $parts = preg_split('/\n(?=' . $next_level . ')/', $gedcom); 598 $return = array_shift($parts); 599 600 foreach ($subtags as $subtag => $occurrences) { 601 if (!$include_hidden && $this->isHiddenTag($tag . ':' . $subtag)) { 602 continue; 603 } 604 605 [$min, $max] = explode(':', $occurrences); 606 607 $min = (int) $min; 608 609 if ($max === 'M') { 610 $max = PHP_INT_MAX; 611 } else { 612 $max = (int) $max; 613 } 614 615 $count = 0; 616 617 // Add expected subtags in our preferred order. 618 foreach ($parts as $n => $part) { 619 if (str_starts_with($part, $next_level . ' ' . $subtag)) { 620 $return .= "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $part, $include_hidden); 621 $count++; 622 unset($parts[$n]); 623 } 624 } 625 626 // Allowed to have more of this subtag? 627 if ($count < $max) { 628 // Create a new one. 629 $gedcom = $next_level . ' ' . $subtag; 630 $default = $factory->make($tag . ':' . $subtag)->default($tree); 631 if ($default !== '') { 632 $gedcom .= ' ' . $default; 633 } 634 635 $number_to_add = max(1, $min - $count); 636 $gedcom_to_add = "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $gedcom, $include_hidden); 637 638 $return .= str_repeat($gedcom_to_add, $number_to_add); 639 } 640 } 641 642 // Now add any unexpected/existing data. 643 if ($parts !== []) { 644 $return .= "\n" . implode("\n", $parts); 645 } 646 647 return $return; 648 } 649 650 /** 651 * List of tags to exclude when creating new data. 652 * 653 * @param string $tag 654 * 655 * @return bool 656 */ 657 private function isHiddenTag(string $tag): bool 658 { 659 // Function to filter hidden tags. 660 $fn_hide = fn (string $x): bool => (bool) Site::getPreference('HIDE_' . $x); 661 662 $preferences = array_filter(Gedcom::HIDDEN_TAGS, $fn_hide, ARRAY_FILTER_USE_KEY); 663 $preferences = array_values($preferences); 664 $hidden_tags = array_merge(...$preferences); 665 666 foreach ($hidden_tags as $hidden_tag) { 667 if (str_contains($tag, $hidden_tag)) { 668 return true; 669 } 670 } 671 672 return false; 673 } 674} 675