17c7d1e03SGreg Roach<?php 27c7d1e03SGreg Roach 37c7d1e03SGreg Roach/** 47c7d1e03SGreg Roach * webtrees: online genealogy 5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team 67c7d1e03SGreg Roach * This program is free software: you can redistribute it and/or modify 77c7d1e03SGreg Roach * it under the terms of the GNU General Public License as published by 87c7d1e03SGreg Roach * the Free Software Foundation, either version 3 of the License, or 97c7d1e03SGreg Roach * (at your option) any later version. 107c7d1e03SGreg Roach * This program is distributed in the hope that it will be useful, 117c7d1e03SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of 127c7d1e03SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 137c7d1e03SGreg Roach * GNU General Public License for more details. 147c7d1e03SGreg Roach * You should have received a copy of the GNU General Public License 1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>. 167c7d1e03SGreg Roach */ 177c7d1e03SGreg Roach 187c7d1e03SGreg Roachdeclare(strict_types=1); 197c7d1e03SGreg Roach 207c7d1e03SGreg Roachnamespace Fisharebest\Webtrees\Services; 217c7d1e03SGreg Roach 22cb62cb3cSGreg Roachuse Fisharebest\Webtrees\Elements\AbstractXrefElement; 23e22e42f7SGreg Roachuse Fisharebest\Webtrees\Fact; 24abdaad0dSGreg Roachuse Fisharebest\Webtrees\Family; 257c7d1e03SGreg Roachuse Fisharebest\Webtrees\Gedcom; 26f8505e4aSGreg Roachuse Fisharebest\Webtrees\GedcomRecord; 27abdaad0dSGreg Roachuse Fisharebest\Webtrees\Individual; 28abdaad0dSGreg Roachuse Fisharebest\Webtrees\Note; 29c2ed51d1SGreg Roachuse Fisharebest\Webtrees\Registry; 30f8505e4aSGreg Roachuse Fisharebest\Webtrees\Site; 317c7d1e03SGreg Roachuse Fisharebest\Webtrees\Tree; 326102047cSGreg Roachuse Illuminate\Support\Collection; 337c7d1e03SGreg Roach 3468ab58c3SGreg Roachuse function array_diff; 35f8505e4aSGreg Roachuse function array_filter; 3668ab58c3SGreg Roachuse function array_keys; 377c7d1e03SGreg Roachuse function array_merge; 38f8505e4aSGreg Roachuse function array_shift; 3968ab58c3SGreg Roachuse function array_slice; 40f8505e4aSGreg Roachuse function array_values; 417c7d1e03SGreg Roachuse function assert; 427c7d1e03SGreg Roachuse function count; 43f8505e4aSGreg Roachuse function explode; 44f8505e4aSGreg Roachuse function implode; 45f8505e4aSGreg Roachuse function max; 46f8505e4aSGreg Roachuse function preg_replace; 47f8505e4aSGreg Roachuse function preg_split; 48a6081838SGreg Roachuse function str_ends_with; 49f8505e4aSGreg Roachuse function str_repeat; 507c7d1e03SGreg Roachuse function str_replace; 516518218eSGreg Roachuse function str_starts_with; 52f8505e4aSGreg Roachuse function substr_count; 536102047cSGreg Roachuse function trim; 547c7d1e03SGreg Roach 559c7bc1e3SGreg Roachuse const ARRAY_FILTER_USE_BOTH; 56f8505e4aSGreg Roachuse const ARRAY_FILTER_USE_KEY; 57f8505e4aSGreg Roachuse const PHP_INT_MAX; 58f8505e4aSGreg Roach 597c7d1e03SGreg Roach/** 607c7d1e03SGreg Roach * Utilities to edit/save GEDCOM data. 617c7d1e03SGreg Roach */ 627c7d1e03SGreg Roachclass GedcomEditService 637c7d1e03SGreg Roach{ 647c7d1e03SGreg Roach /** 656102047cSGreg Roach * @param Tree $tree 666102047cSGreg Roach * 6736779af1SGreg Roach * @return Collection<int,Fact> 686102047cSGreg Roach */ 696102047cSGreg Roach public function newFamilyFacts(Tree $tree): Collection 706102047cSGreg Roach { 716102047cSGreg Roach $dummy = Registry::familyFactory()->new('', '0 @@ FAM', null, $tree); 72eed04cf9SJonathan Jaubart $tags = (new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FAMFACTS')))) 73a2fcce95SGreg Roach ->filter(static fn (string $tag): bool => $tag !== ''); 746102047cSGreg Roach $facts = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag)); 756102047cSGreg Roach 766102047cSGreg Roach return Fact::sortFacts($facts); 776102047cSGreg Roach } 786102047cSGreg Roach 796102047cSGreg Roach /** 806102047cSGreg Roach * @param Tree $tree 816102047cSGreg Roach * @param string $sex 82ddcf848dSGreg Roach * @param array<string> $names 836102047cSGreg Roach * 8436779af1SGreg Roach * @return Collection<int,Fact> 856102047cSGreg Roach */ 866102047cSGreg Roach public function newIndividualFacts(Tree $tree, string $sex, array $names): Collection 876102047cSGreg Roach { 886102047cSGreg Roach $dummy = Registry::individualFactory()->new('', '0 @@ INDI', null, $tree); 89eed04cf9SJonathan Jaubart $tags = (new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FACTS')))) 90a2fcce95SGreg Roach ->filter(static fn (string $tag): bool => $tag !== ''); 916102047cSGreg Roach $facts = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag)); 926102047cSGreg Roach $sex_fact = new Collection([new Fact('1 SEX ' . $sex, $dummy, '')]); 936102047cSGreg Roach $name_facts = Collection::make($names)->map(static fn (string $gedcom): Fact => new Fact($gedcom, $dummy, '')); 946102047cSGreg Roach 956102047cSGreg Roach return $sex_fact->concat($name_facts)->concat(Fact::sortFacts($facts)); 966102047cSGreg Roach } 976102047cSGreg Roach 986102047cSGreg Roach /** 996102047cSGreg Roach * @param GedcomRecord $record 1006102047cSGreg Roach * @param string $tag 1016102047cSGreg Roach * 1026102047cSGreg Roach * @return Fact 1036102047cSGreg Roach */ 1046102047cSGreg Roach private function createNewFact(GedcomRecord $record, string $tag): Fact 1056102047cSGreg Roach { 1066102047cSGreg Roach $element = Registry::elementFactory()->make($record->tag() . ':' . $tag); 1076102047cSGreg Roach $default = $element->default($record->tree()); 1086102047cSGreg Roach $gedcom = trim('1 ' . $tag . ' ' . $default); 1096102047cSGreg Roach 1106102047cSGreg Roach return new Fact($gedcom, $record, ''); 1116102047cSGreg Roach } 1126102047cSGreg Roach 1136102047cSGreg Roach /** 114c2ed51d1SGreg Roach * Reassemble edited GEDCOM fields into a GEDCOM fact/event string. 115c2ed51d1SGreg Roach * 116c2ed51d1SGreg Roach * @param string $record_type 117c2ed51d1SGreg Roach * @param array<string> $levels 118c2ed51d1SGreg Roach * @param array<string> $tags 119c2ed51d1SGreg Roach * @param array<string> $values 120*e5766395SGreg Roach * @param bool $append Are we appending to a level 0 record, or replacing a level 1 record? 121c2ed51d1SGreg Roach * 122c2ed51d1SGreg Roach * @return string 123c2ed51d1SGreg Roach */ 124b389d323SGreg Roach public function editLinesToGedcom(string $record_type, array $levels, array $tags, array $values, bool $append = true): string 125c2ed51d1SGreg Roach { 126c2ed51d1SGreg Roach // Assert all arrays are the same size. 127c2ed51d1SGreg Roach $count = count($levels); 128c2ed51d1SGreg Roach assert($count > 0); 129c2ed51d1SGreg Roach assert(count($tags) === $count); 130c2ed51d1SGreg Roach assert(count($values) === $count); 131c2ed51d1SGreg Roach 132c2ed51d1SGreg Roach $gedcom_lines = []; 133c2ed51d1SGreg Roach $hierarchy = [$record_type]; 134c2ed51d1SGreg Roach 135c2ed51d1SGreg Roach for ($i = 0; $i < $count; $i++) { 136c2ed51d1SGreg Roach $hierarchy[$levels[$i]] = $tags[$i]; 137c2ed51d1SGreg Roach 138c2ed51d1SGreg Roach $full_tag = implode(':', array_slice($hierarchy, 0, 1 + (int) $levels[$i])); 139c2ed51d1SGreg Roach $element = Registry::elementFactory()->make($full_tag); 140c2ed51d1SGreg Roach $values[$i] = $element->canonical($values[$i]); 141c2ed51d1SGreg Roach 142c2ed51d1SGreg Roach // If "1 FACT Y" has a DATE or PLAC, then delete the value of Y 143c2ed51d1SGreg Roach if ($levels[$i] === '1' && $values[$i] === 'Y') { 144c2ed51d1SGreg Roach for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; ++$j) { 145c2ed51d1SGreg Roach if ($levels[$j] === '2' && ($tags[$j] === 'DATE' || $tags[$j] === 'PLAC') && $values[$j] !== '') { 146c2ed51d1SGreg Roach $values[$i] = ''; 147c2ed51d1SGreg Roach break; 148c2ed51d1SGreg Roach } 149c2ed51d1SGreg Roach } 150c2ed51d1SGreg Roach } 151c2ed51d1SGreg Roach 152cefd719cSGreg Roach // Find the next tag at the same level. Check if any child tags have values. 153cb62cb3cSGreg Roach $children_with_values = false; 154cb62cb3cSGreg Roach for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; $j++) { 155cb62cb3cSGreg Roach if ($values[$j] !== '') { 156cb62cb3cSGreg Roach $children_with_values = true; 157cb62cb3cSGreg Roach } 158c2ed51d1SGreg Roach } 159c2ed51d1SGreg Roach 160cb62cb3cSGreg Roach if ($values[$i] !== '' || $children_with_values && !$element instanceof AbstractXrefElement) { 161c2ed51d1SGreg Roach if ($values[$i] === '') { 162c2ed51d1SGreg Roach $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i]; 163c2ed51d1SGreg Roach } else { 164cb62cb3cSGreg Roach // We use CONC for editing NOTE records. 165f7c88e25SGreg Roach if ($tags[$i] === 'CONC') { 166f7c88e25SGreg Roach $next_level = (int) $levels[$i]; 167f7c88e25SGreg Roach } else { 168c2ed51d1SGreg Roach $next_level = 1 + (int) $levels[$i]; 169f7c88e25SGreg Roach } 170c2ed51d1SGreg Roach 171c2ed51d1SGreg Roach $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i] . ' ' . str_replace("\n", "\n" . $next_level . ' CONT ', $values[$i]); 172c2ed51d1SGreg Roach } 173cb62cb3cSGreg Roach } else { 174cefd719cSGreg Roach $i = $j - 1; 175c2ed51d1SGreg Roach } 176c2ed51d1SGreg Roach } 177c2ed51d1SGreg Roach 178b389d323SGreg Roach $gedcom = implode("\n", $gedcom_lines); 179b389d323SGreg Roach 180b389d323SGreg Roach if ($append && $gedcom !== '') { 181b389d323SGreg Roach $gedcom = "\n" . $gedcom; 182b389d323SGreg Roach } 183b389d323SGreg Roach 184b389d323SGreg Roach return $gedcom; 185c2ed51d1SGreg Roach } 186e22e42f7SGreg Roach 187e22e42f7SGreg Roach /** 188e22e42f7SGreg Roach * Add blank lines, to allow a user to add/edit new values. 189e22e42f7SGreg Roach * 190e22e42f7SGreg Roach * @param Fact $fact 191e22e42f7SGreg Roach * @param bool $include_hidden 192e22e42f7SGreg Roach * 193e22e42f7SGreg Roach * @return string 194e22e42f7SGreg Roach */ 195abdaad0dSGreg Roach public function insertMissingFactSubtags(Fact $fact, bool $include_hidden): string 196e22e42f7SGreg Roach { 1976518218eSGreg Roach // Merge CONT records onto their parent line. 1986518218eSGreg Roach $gedcom = preg_replace('/\n\d CONT ?/', "\r", $fact->gedcom()); 1996518218eSGreg Roach 2006518218eSGreg Roach return $this->insertMissingLevels($fact->record()->tree(), $fact->tag(), $gedcom, $include_hidden); 201f8505e4aSGreg Roach } 202f8505e4aSGreg Roach 203f8505e4aSGreg Roach /** 204f8505e4aSGreg Roach * Add blank lines, to allow a user to add/edit new values. 205f8505e4aSGreg Roach * 206f8505e4aSGreg Roach * @param GedcomRecord $record 207f8505e4aSGreg Roach * @param bool $include_hidden 208f8505e4aSGreg Roach * 209f8505e4aSGreg Roach * @return string 210f8505e4aSGreg Roach */ 211f8505e4aSGreg Roach public function insertMissingRecordSubtags(GedcomRecord $record, bool $include_hidden): string 212f8505e4aSGreg Roach { 2136518218eSGreg Roach // Merge CONT records onto their parent line. 2146518218eSGreg Roach $gedcom = preg_replace('/\n\d CONT ?/', "\r", $record->gedcom()); 2156518218eSGreg Roach 2166518218eSGreg Roach $gedcom = $this->insertMissingLevels($record->tree(), $record->tag(), $gedcom, $include_hidden); 217f8505e4aSGreg Roach 218f8505e4aSGreg Roach // NOTE records have data at level 0. Move it to 1 CONC. 219abdaad0dSGreg Roach if ($record instanceof Note) { 220f8505e4aSGreg Roach return preg_replace('/^0 @[^@]+@ NOTE/', '1 CONC', $gedcom); 221f8505e4aSGreg Roach } 222f8505e4aSGreg Roach 223f8505e4aSGreg Roach return preg_replace('/^0.*\n/', '', $gedcom); 224f8505e4aSGreg Roach } 225f8505e4aSGreg Roach 226f8505e4aSGreg Roach /** 227abdaad0dSGreg Roach * List of facts/events to add to families and individuals. 228abdaad0dSGreg Roach * 229abdaad0dSGreg Roach * @param Family|Individual $record 230abdaad0dSGreg Roach * @param bool $include_hidden 231abdaad0dSGreg Roach * 232abdaad0dSGreg Roach * @return array<string> 233abdaad0dSGreg Roach */ 23445ebc6c6SGreg Roach public function factsToAdd(Family|Individual $record, bool $include_hidden): array 235abdaad0dSGreg Roach { 236abdaad0dSGreg Roach $subtags = Registry::elementFactory()->make($record->tag())->subtags(); 237abdaad0dSGreg Roach 23805babb96SGreg Roach $subtags = array_filter($subtags, static fn (string $v, string $k) => !str_ends_with($v, ':1') || $record->facts([$k])->isEmpty(), ARRAY_FILTER_USE_BOTH); 2399c7bc1e3SGreg Roach 2409c7bc1e3SGreg Roach $subtags = array_keys($subtags); 2419c7bc1e3SGreg Roach 242686ef499SGreg Roach // Don't include facts/events that we have hidden in the control panel. 2437c3bf6b0SGreg Roach $subtags = array_filter($subtags, fn (string $subtag): bool => !$this->isHiddenTag($record->tag() . ':' . $subtag)); 2447c3bf6b0SGreg Roach 245abdaad0dSGreg Roach if (!$include_hidden) { 246abdaad0dSGreg Roach $fn_hidden = fn (string $t): bool => !$this->isHiddenTag($record->tag() . ':' . $t); 247abdaad0dSGreg Roach $subtags = array_filter($subtags, $fn_hidden); 248abdaad0dSGreg Roach } 249abdaad0dSGreg Roach 25068ab58c3SGreg Roach return array_diff($subtags, ['HUSB', 'WIFE', 'CHIL', 'FAMC', 'FAMS', 'CHAN']); 251abdaad0dSGreg Roach } 252abdaad0dSGreg Roach 253abdaad0dSGreg Roach /** 254f8505e4aSGreg Roach * @param Tree $tree 255f8505e4aSGreg Roach * @param string $tag 256f8505e4aSGreg Roach * @param string $gedcom 257f8505e4aSGreg Roach * @param bool $include_hidden 258f8505e4aSGreg Roach * 259f8505e4aSGreg Roach * @return string 260f8505e4aSGreg Roach */ 261f8505e4aSGreg Roach protected function insertMissingLevels(Tree $tree, string $tag, string $gedcom, bool $include_hidden): string 262f8505e4aSGreg Roach { 263f8505e4aSGreg Roach $next_level = substr_count($tag, ':') + 1; 264f8505e4aSGreg Roach $factory = Registry::elementFactory(); 265f8505e4aSGreg Roach $subtags = $factory->make($tag)->subtags(); 266f8505e4aSGreg Roach 267f8505e4aSGreg Roach // The first part is level N. The remainder are level N+1. 268f8505e4aSGreg Roach $parts = preg_split('/\n(?=' . $next_level . ')/', $gedcom); 2694502a888SGreg Roach $return = array_shift($parts) ?? ''; 270f8505e4aSGreg Roach 271f8505e4aSGreg Roach foreach ($subtags as $subtag => $occurrences) { 272a6081838SGreg Roach $hidden = str_ends_with($occurrences, ':?') || $this->isHiddenTag($tag . ':' . $subtag); 273a6081838SGreg Roach 274a6081838SGreg Roach if (!$include_hidden && $hidden) { 275f8505e4aSGreg Roach continue; 276f8505e4aSGreg Roach } 277f8505e4aSGreg Roach 278f8505e4aSGreg Roach [$min, $max] = explode(':', $occurrences); 279f8505e4aSGreg Roach 280f8505e4aSGreg Roach $min = (int) $min; 281f8505e4aSGreg Roach 282f8505e4aSGreg Roach if ($max === 'M') { 283f8505e4aSGreg Roach $max = PHP_INT_MAX; 284f8505e4aSGreg Roach } else { 285f8505e4aSGreg Roach $max = (int) $max; 286f8505e4aSGreg Roach } 287f8505e4aSGreg Roach 288f8505e4aSGreg Roach $count = 0; 289f8505e4aSGreg Roach 290f8505e4aSGreg Roach // Add expected subtags in our preferred order. 291f8505e4aSGreg Roach foreach ($parts as $n => $part) { 292f8505e4aSGreg Roach if (str_starts_with($part, $next_level . ' ' . $subtag)) { 293f8505e4aSGreg Roach $return .= "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $part, $include_hidden); 294f8505e4aSGreg Roach $count++; 295f8505e4aSGreg Roach unset($parts[$n]); 296f8505e4aSGreg Roach } 297f8505e4aSGreg Roach } 298f8505e4aSGreg Roach 299f8505e4aSGreg Roach // Allowed to have more of this subtag? 300f8505e4aSGreg Roach if ($count < $max) { 301f8505e4aSGreg Roach // Create a new one. 302f8505e4aSGreg Roach $gedcom = $next_level . ' ' . $subtag; 303f8505e4aSGreg Roach $default = $factory->make($tag . ':' . $subtag)->default($tree); 304f8505e4aSGreg Roach if ($default !== '') { 305f8505e4aSGreg Roach $gedcom .= ' ' . $default; 306f8505e4aSGreg Roach } 307f8505e4aSGreg Roach 308f8505e4aSGreg Roach $number_to_add = max(1, $min - $count); 309f8505e4aSGreg Roach $gedcom_to_add = "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $gedcom, $include_hidden); 310f8505e4aSGreg Roach 311f8505e4aSGreg Roach $return .= str_repeat($gedcom_to_add, $number_to_add); 312f8505e4aSGreg Roach } 313f8505e4aSGreg Roach } 314f8505e4aSGreg Roach 315f8505e4aSGreg Roach // Now add any unexpected/existing data. 316f8505e4aSGreg Roach if ($parts !== []) { 317f8505e4aSGreg Roach $return .= "\n" . implode("\n", $parts); 318f8505e4aSGreg Roach } 319f8505e4aSGreg Roach 320f8505e4aSGreg Roach return $return; 321f8505e4aSGreg Roach } 322f8505e4aSGreg Roach 323f8505e4aSGreg Roach /** 324f8505e4aSGreg Roach * List of tags to exclude when creating new data. 325f8505e4aSGreg Roach * 326f8505e4aSGreg Roach * @param string $tag 327f8505e4aSGreg Roach * 328f8505e4aSGreg Roach * @return bool 329f8505e4aSGreg Roach */ 330f8505e4aSGreg Roach private function isHiddenTag(string $tag): bool 331f8505e4aSGreg Roach { 332a9da2374SGreg Roach // Function to filter hidden tags. 33305babb96SGreg Roach $fn_hide = static fn (string $x): bool => (bool) Site::getPreference('HIDE_' . $x); 334a9da2374SGreg Roach 335a9da2374SGreg Roach $preferences = array_filter(Gedcom::HIDDEN_TAGS, $fn_hide, ARRAY_FILTER_USE_KEY); 336f8505e4aSGreg Roach $preferences = array_values($preferences); 337f8505e4aSGreg Roach $hidden_tags = array_merge(...$preferences); 338f8505e4aSGreg Roach 339f8505e4aSGreg Roach foreach ($hidden_tags as $hidden_tag) { 340f8505e4aSGreg Roach if (str_contains($tag, $hidden_tag)) { 341f8505e4aSGreg Roach return true; 342f8505e4aSGreg Roach } 343f8505e4aSGreg Roach } 344f8505e4aSGreg Roach 345f8505e4aSGreg Roach return false; 346e22e42f7SGreg Roach } 3477c7d1e03SGreg Roach} 348