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