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