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