1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2022 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\Elements\AbstractXrefElement; 23use Fisharebest\Webtrees\Fact; 24use Fisharebest\Webtrees\Family; 25use Fisharebest\Webtrees\Gedcom; 26use Fisharebest\Webtrees\GedcomRecord; 27use Fisharebest\Webtrees\Individual; 28use Fisharebest\Webtrees\Note; 29use Fisharebest\Webtrees\Registry; 30use Fisharebest\Webtrees\Site; 31use Fisharebest\Webtrees\Tree; 32use Illuminate\Support\Collection; 33use Illuminate\Support\Str; 34 35use function array_diff; 36use function array_filter; 37use function array_keys; 38use function array_merge; 39use function array_shift; 40use function array_slice; 41use function array_values; 42use function assert; 43use function count; 44use function explode; 45use function implode; 46use function max; 47use function preg_replace; 48use function preg_split; 49use function str_ends_with; 50use function str_repeat; 51use function str_replace; 52use function str_starts_with; 53use function substr_count; 54use function trim; 55 56use const ARRAY_FILTER_USE_BOTH; 57use const ARRAY_FILTER_USE_KEY; 58use const PHP_INT_MAX; 59 60/** 61 * Utilities to edit/save GEDCOM data. 62 */ 63class GedcomEditService 64{ 65 /** 66 * @param Tree $tree 67 * 68 * @return Collection<int,Fact> 69 */ 70 public function newFamilyFacts(Tree $tree): Collection 71 { 72 $dummy = Registry::familyFactory()->new('', '0 @@ FAM', null, $tree); 73 $tags = (new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FAMFACTS')))) 74 ->filter(static fn (string $tag): bool => Str::length($tag) > 0); 75 $facts = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag)); 76 77 return Fact::sortFacts($facts); 78 } 79 80 /** 81 * @param Tree $tree 82 * @param string $sex 83 * @param array<string> $names 84 * 85 * @return Collection<int,Fact> 86 */ 87 public function newIndividualFacts(Tree $tree, string $sex, array $names): Collection 88 { 89 $dummy = Registry::individualFactory()->new('', '0 @@ INDI', null, $tree); 90 $tags = (new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FACTS')))) 91 ->filter(static fn (string $tag): bool => Str::length($tag) > 0); 92 $facts = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag)); 93 $sex_fact = new Collection([new Fact('1 SEX ' . $sex, $dummy, '')]); 94 $name_facts = Collection::make($names)->map(static fn (string $gedcom): Fact => new Fact($gedcom, $dummy, '')); 95 96 return $sex_fact->concat($name_facts)->concat(Fact::sortFacts($facts)); 97 } 98 99 /** 100 * @param GedcomRecord $record 101 * @param string $tag 102 * 103 * @return Fact 104 */ 105 private function createNewFact(GedcomRecord $record, string $tag): Fact 106 { 107 $element = Registry::elementFactory()->make($record->tag() . ':' . $tag); 108 $default = $element->default($record->tree()); 109 $gedcom = trim('1 ' . $tag . ' ' . $default); 110 111 return new Fact($gedcom, $record, ''); 112 } 113 114 /** 115 * Reassemble edited GEDCOM fields into a GEDCOM fact/event string. 116 * 117 * @param string $record_type 118 * @param array<string> $levels 119 * @param array<string> $tags 120 * @param array<string> $values 121 * @param bool $append - Are we appending to a level 0 record, or replacing a level 1 record? 122 * 123 * @return string 124 */ 125 public function editLinesToGedcom(string $record_type, array $levels, array $tags, array $values, bool $append = true): string 126 { 127 // Assert all arrays are the same size. 128 $count = count($levels); 129 assert($count > 0); 130 assert(count($tags) === $count); 131 assert(count($values) === $count); 132 133 $gedcom_lines = []; 134 $hierarchy = [$record_type]; 135 136 for ($i = 0; $i < $count; $i++) { 137 $hierarchy[$levels[$i]] = $tags[$i]; 138 139 $full_tag = implode(':', array_slice($hierarchy, 0, 1 + (int) $levels[$i])); 140 $element = Registry::elementFactory()->make($full_tag); 141 $values[$i] = $element->canonical($values[$i]); 142 143 // If "1 FACT Y" has a DATE or PLAC, then delete the value of Y 144 if ($levels[$i] === '1' && $values[$i] === 'Y') { 145 for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; ++$j) { 146 if ($levels[$j] === '2' && ($tags[$j] === 'DATE' || $tags[$j] === 'PLAC') && $values[$j] !== '') { 147 $values[$i] = ''; 148 break; 149 } 150 } 151 } 152 153 // Find the next tag at the same level. Check if any child tags have values. 154 $children_with_values = false; 155 for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; $j++) { 156 if ($values[$j] !== '') { 157 $children_with_values = true; 158 } 159 } 160 161 if ($values[$i] !== '' || $children_with_values && !$element instanceof AbstractXrefElement) { 162 if ($values[$i] === '') { 163 $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i]; 164 } else { 165 // We use CONC for editing NOTE records. 166 if ($tags[$i] === 'CONC') { 167 $next_level = (int) $levels[$i]; 168 } else { 169 $next_level = 1 + (int) $levels[$i]; 170 } 171 172 $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i] . ' ' . str_replace("\n", "\n" . $next_level . ' CONT ', $values[$i]); 173 } 174 } else { 175 $i = $j - 1; 176 } 177 } 178 179 $gedcom = implode("\n", $gedcom_lines); 180 181 if ($append && $gedcom !== '') { 182 $gedcom = "\n" . $gedcom; 183 } 184 185 return $gedcom; 186 } 187 188 /** 189 * Add blank lines, to allow a user to add/edit new values. 190 * 191 * @param Fact $fact 192 * @param bool $include_hidden 193 * 194 * @return string 195 */ 196 public function insertMissingFactSubtags(Fact $fact, bool $include_hidden): string 197 { 198 // Merge CONT records onto their parent line. 199 $gedcom = preg_replace('/\n\d CONT ?/', "\r", $fact->gedcom()); 200 201 return $this->insertMissingLevels($fact->record()->tree(), $fact->tag(), $gedcom, $include_hidden); 202 } 203 204 /** 205 * Add blank lines, to allow a user to add/edit new values. 206 * 207 * @param GedcomRecord $record 208 * @param bool $include_hidden 209 * 210 * @return string 211 */ 212 public function insertMissingRecordSubtags(GedcomRecord $record, bool $include_hidden): string 213 { 214 // Merge CONT records onto their parent line. 215 $gedcom = preg_replace('/\n\d CONT ?/', "\r", $record->gedcom()); 216 217 $gedcom = $this->insertMissingLevels($record->tree(), $record->tag(), $gedcom, $include_hidden); 218 219 // NOTE records have data at level 0. Move it to 1 CONC. 220 if ($record instanceof Note) { 221 return preg_replace('/^0 @[^@]+@ NOTE/', '1 CONC', $gedcom); 222 } 223 224 return preg_replace('/^0.*\n/', '', $gedcom); 225 } 226 227 /** 228 * List of facts/events to add to families and individuals. 229 * 230 * @param Family|Individual $record 231 * @param bool $include_hidden 232 * 233 * @return array<string> 234 */ 235 public function factsToAdd(GedcomRecord $record, bool $include_hidden): array 236 { 237 $subtags = Registry::elementFactory()->make($record->tag())->subtags(); 238 239 $subtags = array_filter($subtags, static fn (string $v, string $k) => !str_ends_with($v, ':1') || $record->facts([$k])->isEmpty(), ARRAY_FILTER_USE_BOTH); 240 241 $subtags = array_keys($subtags); 242 243 // Don't include facts/events that we have hidden in the control panel. 244 $subtags = array_filter($subtags, fn (string $subtag): bool => !$this->isHiddenTag($record->tag() . ':' . $subtag)); 245 246 if (!$include_hidden) { 247 $fn_hidden = fn (string $t): bool => !$this->isHiddenTag($record->tag() . ':' . $t); 248 $subtags = array_filter($subtags, $fn_hidden); 249 } 250 251 return array_diff($subtags, ['HUSB', 'WIFE', 'CHIL', 'FAMC', 'FAMS', 'CHAN']); 252 } 253 254 /** 255 * @param Tree $tree 256 * @param string $tag 257 * @param string $gedcom 258 * @param bool $include_hidden 259 * 260 * @return string 261 */ 262 protected function insertMissingLevels(Tree $tree, string $tag, string $gedcom, bool $include_hidden): string 263 { 264 $next_level = substr_count($tag, ':') + 1; 265 $factory = Registry::elementFactory(); 266 $subtags = $factory->make($tag)->subtags(); 267 268 // The first part is level N. The remainder are level N+1. 269 $parts = preg_split('/\n(?=' . $next_level . ')/', $gedcom); 270 $return = array_shift($parts) ?? ''; 271 272 foreach ($subtags as $subtag => $occurrences) { 273 $hidden = str_ends_with($occurrences, ':?') || $this->isHiddenTag($tag . ':' . $subtag); 274 275 if (!$include_hidden && $hidden) { 276 continue; 277 } 278 279 [$min, $max] = explode(':', $occurrences); 280 281 $min = (int) $min; 282 283 if ($max === 'M') { 284 $max = PHP_INT_MAX; 285 } else { 286 $max = (int) $max; 287 } 288 289 $count = 0; 290 291 // Add expected subtags in our preferred order. 292 foreach ($parts as $n => $part) { 293 if (str_starts_with($part, $next_level . ' ' . $subtag)) { 294 $return .= "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $part, $include_hidden); 295 $count++; 296 unset($parts[$n]); 297 } 298 } 299 300 // Allowed to have more of this subtag? 301 if ($count < $max) { 302 // Create a new one. 303 $gedcom = $next_level . ' ' . $subtag; 304 $default = $factory->make($tag . ':' . $subtag)->default($tree); 305 if ($default !== '') { 306 $gedcom .= ' ' . $default; 307 } 308 309 $number_to_add = max(1, $min - $count); 310 $gedcom_to_add = "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $gedcom, $include_hidden); 311 312 $return .= str_repeat($gedcom_to_add, $number_to_add); 313 } 314 } 315 316 // Now add any unexpected/existing data. 317 if ($parts !== []) { 318 $return .= "\n" . implode("\n", $parts); 319 } 320 321 return $return; 322 } 323 324 /** 325 * List of tags to exclude when creating new data. 326 * 327 * @param string $tag 328 * 329 * @return bool 330 */ 331 private function isHiddenTag(string $tag): bool 332 { 333 // Function to filter hidden tags. 334 $fn_hide = static fn (string $x): bool => (bool) Site::getPreference('HIDE_' . $x); 335 336 $preferences = array_filter(Gedcom::HIDDEN_TAGS, $fn_hide, ARRAY_FILTER_USE_KEY); 337 $preferences = array_values($preferences); 338 $hidden_tags = array_merge(...$preferences); 339 340 foreach ($hidden_tags as $hidden_tag) { 341 if (str_contains($tag, $hidden_tag)) { 342 return true; 343 } 344 } 345 346 return false; 347 } 348} 349