xref: /webtrees/app/Services/GedcomEditService.php (revision eed04cf92eb519b30002669d271d3ec8fc215449)
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