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\Gedcom; 24use Fisharebest\Webtrees\Registry; 25use Fisharebest\Webtrees\Tree; 26use Psr\Http\Message\ServerRequestInterface; 27 28use function array_merge; 29use function array_unique; 30use function assert; 31use function count; 32use function preg_match_all; 33use function str_replace; 34use function trim; 35 36/** 37 * Utilities to edit/save GEDCOM data. 38 */ 39class GedcomEditService 40{ 41 /** @var string[] */ 42 public $glevels = []; 43 44 /** @var string[] */ 45 public $tag = []; 46 47 /** @var string[] */ 48 public $islink = []; 49 50 /** @var string[] */ 51 public $text = []; 52 53 /** @var string[] */ 54 protected $glevelsSOUR = []; 55 56 /** @var string[] */ 57 protected $tagSOUR = []; 58 59 /** @var string[] */ 60 protected $islinkSOUR = []; 61 62 /** @var string[] */ 63 protected $textSOUR = []; 64 65 /** @var string[] */ 66 protected $glevelsRest = []; 67 68 /** @var string[] */ 69 protected $tagRest = []; 70 71 /** @var string[] */ 72 protected $islinkRest = []; 73 74 /** @var string[] */ 75 protected $textRest = []; 76 77 /** 78 * This function splits the $glevels, $tag, $islink, and $text arrays so that the 79 * entries associated with a SOUR record are separate from everything else. 80 * 81 * Input arrays: 82 * - $glevels[] - an array of the gedcom level for each line that was edited 83 * - $tag[] - an array of the tags for each gedcom line that was edited 84 * - $islink[] - an array of 1 or 0 values to indicate when the text is a link element 85 * - $text[] - an array of the text data for each line 86 * 87 * Output arrays: 88 * ** For the SOUR record: 89 * - $glevelsSOUR[] - an array of the gedcom level for each line that was edited 90 * - $tagSOUR[] - an array of the tags for each gedcom line that was edited 91 * - $islinkSOUR[] - an array of 1 or 0 values to indicate when the text is a link element 92 * - $textSOUR[] - an array of the text data for each line 93 * ** For the remaining records: 94 * - $glevelsRest[] - an array of the gedcom level for each line that was edited 95 * - $tagRest[] - an array of the tags for each gedcom line that was edited 96 * - $islinkRest[] - an array of 1 or 0 values to indicate when the text is a link element 97 * - $textRest[] - an array of the text data for each line 98 * 99 * @return void 100 */ 101 public function splitSource(): void 102 { 103 $this->glevelsSOUR = []; 104 $this->tagSOUR = []; 105 $this->islinkSOUR = []; 106 $this->textSOUR = []; 107 108 $this->glevelsRest = []; 109 $this->tagRest = []; 110 $this->islinkRest = []; 111 $this->textRest = []; 112 113 $inSOUR = false; 114 $levelSOUR = 0; 115 116 // Assume all arrays are the same size. 117 $count = count($this->glevels); 118 119 for ($i = 0; $i < $count; $i++) { 120 if ($inSOUR) { 121 if ($levelSOUR < $this->glevels[$i]) { 122 $dest = 'S'; 123 } else { 124 $inSOUR = false; 125 $dest = 'R'; 126 } 127 } elseif ($this->tag[$i] === 'SOUR') { 128 $inSOUR = true; 129 $levelSOUR = $this->glevels[$i]; 130 $dest = 'S'; 131 } else { 132 $dest = 'R'; 133 } 134 135 if ($dest === 'S') { 136 $this->glevelsSOUR[] = $this->glevels[$i]; 137 $this->tagSOUR[] = $this->tag[$i]; 138 $this->islinkSOUR[] = $this->islink[$i]; 139 $this->textSOUR[] = $this->text[$i]; 140 } else { 141 $this->glevelsRest[] = $this->glevels[$i]; 142 $this->tagRest[] = $this->tag[$i]; 143 $this->islinkRest[] = $this->islink[$i]; 144 $this->textRest[] = $this->text[$i]; 145 } 146 } 147 } 148 149 /** 150 * Add new GEDCOM lines from the $xxxRest interface update arrays, which 151 * were produced by the splitSOUR() function. 152 * See the FunctionsEdit::handle_updatesges() function for details. 153 * 154 * @param string $inputRec 155 * 156 * @return string 157 */ 158 public function updateRest(string $inputRec): string 159 { 160 if (count($this->tagRest) === 0) { 161 return $inputRec; // No update required 162 } 163 164 // Save original interface update arrays before replacing them with the xxxRest ones 165 $glevelsSave = $this->glevels; 166 $tagSave = $this->tag; 167 $islinkSave = $this->islink; 168 $textSave = $this->text; 169 170 $this->glevels = $this->glevelsRest; 171 $this->tag = $this->tagRest; 172 $this->islink = $this->islinkRest; 173 $this->text = $this->textRest; 174 175 $myRecord = $this->handleUpdates($inputRec, 'no'); // Now do the update 176 177 // Restore the original interface update arrays (just in case ...) 178 $this->glevels = $glevelsSave; 179 $this->tag = $tagSave; 180 $this->islink = $islinkSave; 181 $this->text = $textSave; 182 183 return $myRecord; 184 } 185 186 /** 187 * Add new gedcom lines from interface update arrays 188 * The edit_interface and FunctionsEdit::add_simple_tag function produce the following 189 * arrays incoming from the $_POST form 190 * - $glevels[] - an array of the gedcom level for each line that was edited 191 * - $tag[] - an array of the tags for each gedcom line that was edited 192 * - $islink[] - an array of 1 or 0 values to tell whether the text is a link element and should be surrounded by @@ 193 * - $text[] - an array of the text data for each line 194 * With these arrays you can recreate the gedcom lines like this 195 * <code>$glevel[0].' '.$tag[0].' '.$text[0]</code> 196 * There will be an index in each of these arrays for each line of the gedcom 197 * fact that is being edited. 198 * If the $text[] array is empty for the given line, then it means that the 199 * user removed that line during editing or that the line is supposed to be 200 * empty (1 DEAT, 1 BIRT) for example. To know if the line should be removed 201 * there is a section of code that looks ahead to the next lines to see if there 202 * are sub lines. For example we don't want to remove the 1 DEAT line if it has 203 * a 2 PLAC or 2 DATE line following it. If there are no sub lines, then the line 204 * can be safely removed. 205 * 206 * @param string $newged the new gedcom record to add the lines to 207 * @param string $levelOverride Override GEDCOM level specified in $glevels[0] 208 * 209 * @return string The updated gedcom record 210 */ 211 public function handleUpdates(string $newged, string $levelOverride = 'no'): string 212 { 213 if ($levelOverride === 'no') { 214 $levelAdjust = 0; 215 } else { 216 $levelAdjust = 1; 217 } 218 219 // Assert all arrays are the same size. 220 assert(count($this->glevels) === count($this->tag)); 221 assert(count($this->glevels) === count($this->text)); 222 assert(count($this->glevels) === count($this->islink)); 223 224 $count = count($this->glevels); 225 226 for ($j = 0; $j < $count; $j++) { 227 // Look for empty SOUR reference with non-empty sub-records. 228 // This can happen when the SOUR entry is deleted but its sub-records 229 // were incorrectly left intact. 230 // The sub-records should be deleted. 231 if ($this->tag[$j] === 'SOUR' && ($this->text[$j] === '@@' || $this->text[$j] === '')) { 232 $this->text[$j] = ''; 233 $k = $j + 1; 234 while ($k < $count && $this->glevels[$k] > $this->glevels[$j]) { 235 $this->text[$k] = ''; 236 $k++; 237 } 238 } 239 240 if (trim($this->text[$j]) !== '') { 241 $pass = true; 242 } else { 243 //-- for facts with empty values they must have sub records 244 //-- this section checks if they have subrecords 245 $k = $j + 1; 246 $pass = false; 247 while ($k < $count && $this->glevels[$k] > $this->glevels[$j]) { 248 if ($this->text[$k] !== '') { 249 if ($this->tag[$j] !== 'OBJE' || $this->tag[$k] === 'FILE') { 250 $pass = true; 251 break; 252 } 253 } 254 $k++; 255 } 256 } 257 258 //-- if the value is not empty or it has sub lines 259 //--- then write the line to the gedcom record 260 //-- we have to let some emtpy text lines pass through... (DEAT, BIRT, etc) 261 if ($pass) { 262 $newline = (int) $this->glevels[$j] + $levelAdjust . ' ' . $this->tag[$j]; 263 if ($this->text[$j] !== '') { 264 if ($this->islink[$j]) { 265 $newline .= ' @' . trim($this->text[$j], '@') . '@'; 266 } else { 267 $newline .= ' ' . $this->text[$j]; 268 } 269 } 270 $next_level = 1 + (int) $this->glevels[$j] + $levelAdjust; 271 272 $newged .= "\n" . str_replace("\n", "\n" . $next_level . ' CONT ', $newline); 273 } 274 } 275 276 return $newged; 277 } 278 279 /** 280 * Create a form to add a new fact. 281 * 282 * @param ServerRequestInterface $request 283 * @param Tree $tree 284 * @param string $fact 285 * 286 * @return string 287 */ 288 public function addNewFact(ServerRequestInterface $request, Tree $tree, string $fact): string 289 { 290 $params = (array) $request->getParsedBody(); 291 292 $FACT = $params[$fact]; 293 $DATE = $params[$fact . '_DATE'] ?? ''; 294 $PLAC = $params[$fact . '_PLAC'] ?? ''; 295 296 if ($DATE !== '' || $PLAC !== '' || $FACT !== '' && $FACT !== 'Y') { 297 if ($FACT !== '' && $FACT !== 'Y') { 298 $gedrec = "\n1 " . $fact . ' ' . $FACT; 299 } else { 300 $gedrec = "\n1 " . $fact; 301 } 302 if ($DATE !== '') { 303 $gedrec .= "\n2 DATE " . $DATE; 304 } 305 if ($PLAC !== '') { 306 $gedrec .= "\n2 PLAC " . $PLAC; 307 308 if (preg_match_all('/(' . Gedcom::REGEX_TAG . ')/', $tree->getPreference('ADVANCED_PLAC_FACTS'), $match)) { 309 foreach ($match[1] as $tag) { 310 $TAG = $params[$fact . '_' . $tag]; 311 if ($TAG !== '') { 312 $gedrec .= "\n3 " . $tag . ' ' . $TAG; 313 } 314 } 315 } 316 $LATI = $params[$fact . '_LATI'] ?? ''; 317 $LONG = $params[$fact . '_LONG'] ?? ''; 318 if ($LATI !== '' || $LONG !== '') { 319 $gedrec .= "\n3 MAP\n4 LATI " . $LATI . "\n4 LONG " . $LONG; 320 } 321 } 322 if ((bool) ($params['SOUR_' . $fact] ?? false)) { 323 return $this->updateSource($gedrec, 'yes'); 324 } 325 326 return $gedrec; 327 } 328 329 if ($FACT === 'Y') { 330 if ((bool) ($params['SOUR_' . $fact] ?? false)) { 331 return $this->updateSource("\n1 " . $fact . ' Y', 'yes'); 332 } 333 334 return "\n1 " . $fact . ' Y'; 335 } 336 337 return ''; 338 } 339 340 /** 341 * Add new GEDCOM lines from the $xxxSOUR interface update arrays, which 342 * were produced by the splitSOUR() function. 343 * See the FunctionsEdit::handle_updatesges() function for details. 344 * 345 * @param string $inputRec 346 * @param string $levelOverride 347 * 348 * @return string 349 */ 350 public function updateSource(string $inputRec, string $levelOverride = 'no'): string 351 { 352 if (count($this->tagSOUR) === 0) { 353 return $inputRec; // No update required 354 } 355 356 // Save original interface update arrays before replacing them with the xxxSOUR ones 357 $glevelsSave = $this->glevels; 358 $tagSave = $this->tag; 359 $islinkSave = $this->islink; 360 $textSave = $this->text; 361 362 $this->glevels = $this->glevelsSOUR; 363 $this->tag = $this->tagSOUR; 364 $this->islink = $this->islinkSOUR; 365 $this->text = $this->textSOUR; 366 367 $myRecord = $this->handleUpdates($inputRec, $levelOverride); // Now do the update 368 369 // Restore the original interface update arrays (just in case ...) 370 $this->glevels = $glevelsSave; 371 $this->tag = $tagSave; 372 $this->islink = $islinkSave; 373 $this->text = $textSave; 374 375 return $myRecord; 376 } 377 378 /** 379 * Create a form to add a sex record. 380 * 381 * @param ServerRequestInterface $request 382 * 383 * @return string 384 */ 385 public function addNewSex(ServerRequestInterface $request): string 386 { 387 $params = (array) $request->getParsedBody(); 388 389 switch ($params['SEX']) { 390 case 'M': 391 return "\n1 SEX M"; 392 case 'F': 393 return "\n1 SEX F"; 394 default: 395 return "\n1 SEX U"; 396 } 397 } 398 399 /** 400 * Assemble the pieces of a newly created record into gedcom 401 * 402 * @param ServerRequestInterface $request 403 * @param Tree $tree 404 * 405 * @return string 406 */ 407 public function addNewName(ServerRequestInterface $request, Tree $tree): string 408 { 409 $params = (array) $request->getParsedBody(); 410 $gedrec = "\n1 NAME " . $params['NAME']; 411 412 $tags = [ 413 'NPFX', 414 'GIVN', 415 'SPFX', 416 'SURN', 417 'NSFX', 418 'NICK', 419 ]; 420 421 if (preg_match_all('/(' . Gedcom::REGEX_TAG . ')/', $tree->getPreference('ADVANCED_NAME_FACTS'), $match)) { 422 $tags = array_merge($tags, $match[1]); 423 } 424 425 // Paternal and Polish and Lithuanian surname traditions can also create a _MARNM 426 $SURNAME_TRADITION = $tree->getPreference('SURNAME_TRADITION'); 427 if ($SURNAME_TRADITION === 'paternal' || $SURNAME_TRADITION === 'polish' || $SURNAME_TRADITION === 'lithuanian') { 428 $tags[] = '_MARNM'; 429 } 430 431 foreach (array_unique($tags) as $tag) { 432 $TAG = $params[$tag]; 433 434 if ($TAG !== '') { 435 $gedrec .= "\n2 " . $tag . ' ' . $TAG; 436 } 437 } 438 439 return $gedrec; 440 } 441 442 /** 443 * Reassemble edited GEDCOM fields into a GEDCOM fact/event string. 444 * 445 * @param string $record_type 446 * @param array<string> $levels 447 * @param array<string> $tags 448 * @param array<string> $values 449 * 450 * @return string 451 */ 452 public function editLinesToGedcom(string $record_type, array $levels, array $tags, array $values): string 453 { 454 // Assert all arrays are the same size. 455 $count = count($levels); 456 assert($count > 0); 457 assert(count($tags) === $count); 458 assert(count($values) === $count); 459 460 $gedcom_lines = []; 461 $hierarchy = [$record_type]; 462 463 for ($i = 0; $i < $count; $i++) { 464 $hierarchy[$levels[$i]] = $tags[$i]; 465 466 $full_tag = implode(':', array_slice($hierarchy, 0, 1 + (int) $levels[$i])); 467 $element = Registry::elementFactory()->make($full_tag); 468 $values[$i] = $element->canonical($values[$i]); 469 470 // If "1 FACT Y" has a DATE or PLAC, then delete the value of Y 471 if ($levels[$i] === '1' && $values[$i] === 'Y') { 472 for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; ++$j) { 473 if ($levels[$j] === '2' && ($tags[$j] === 'DATE' || $tags[$j] === 'PLAC') && $values[$j] !== '') { 474 $values[$i] = ''; 475 break; 476 } 477 } 478 } 479 480 // Include this line if there is a value - or if there is a child record with a value. 481 $include = $values[$i] !== ''; 482 483 for ($j = $i + 1; !$include && $j < $count && $levels[$j] > $levels[$i]; $j++) { 484 $include = $values[$j] !== ''; 485 } 486 487 if ($include) { 488 if ($values[$i] === '') { 489 $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i]; 490 } else { 491 if ($tags[$i] === 'CONC') { 492 $next_level = (int) $levels[$i]; 493 } else { 494 $next_level = 1 + (int) $levels[$i]; 495 } 496 497 $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i] . ' ' . str_replace("\n", "\n" . $next_level . ' CONT ', $values[$i]); 498 } 499 } 500 } 501 502 return implode("\n", $gedcom_lines); 503 } 504 505 /** 506 * Add blank lines, to allow a user to add/edit new values. 507 * 508 * @param Fact $fact 509 * @param bool $include_hidden 510 * 511 * @return string 512 */ 513 public function insertMissingSubtags(Fact $fact, bool $include_hidden): string 514 { 515 return $fact->record()->insertMissingLevels($fact->tag(), $fact->gedcom(), $include_hidden); 516 } 517} 518