xref: /webtrees/app/Services/GedcomEditService.php (revision e5766395c1a71e715ebaadcf2d63d036d60fb649)
17c7d1e03SGreg Roach<?php
27c7d1e03SGreg Roach
37c7d1e03SGreg Roach/**
47c7d1e03SGreg Roach * webtrees: online genealogy
5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
67c7d1e03SGreg Roach * This program is free software: you can redistribute it and/or modify
77c7d1e03SGreg Roach * it under the terms of the GNU General Public License as published by
87c7d1e03SGreg Roach * the Free Software Foundation, either version 3 of the License, or
97c7d1e03SGreg Roach * (at your option) any later version.
107c7d1e03SGreg Roach * This program is distributed in the hope that it will be useful,
117c7d1e03SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
127c7d1e03SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
137c7d1e03SGreg Roach * GNU General Public License for more details.
147c7d1e03SGreg Roach * You should have received a copy of the GNU General Public License
1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
167c7d1e03SGreg Roach */
177c7d1e03SGreg Roach
187c7d1e03SGreg Roachdeclare(strict_types=1);
197c7d1e03SGreg Roach
207c7d1e03SGreg Roachnamespace Fisharebest\Webtrees\Services;
217c7d1e03SGreg Roach
22cb62cb3cSGreg Roachuse Fisharebest\Webtrees\Elements\AbstractXrefElement;
23e22e42f7SGreg Roachuse Fisharebest\Webtrees\Fact;
24abdaad0dSGreg Roachuse Fisharebest\Webtrees\Family;
257c7d1e03SGreg Roachuse Fisharebest\Webtrees\Gedcom;
26f8505e4aSGreg Roachuse Fisharebest\Webtrees\GedcomRecord;
27abdaad0dSGreg Roachuse Fisharebest\Webtrees\Individual;
28abdaad0dSGreg Roachuse Fisharebest\Webtrees\Note;
29c2ed51d1SGreg Roachuse Fisharebest\Webtrees\Registry;
30f8505e4aSGreg Roachuse Fisharebest\Webtrees\Site;
317c7d1e03SGreg Roachuse Fisharebest\Webtrees\Tree;
326102047cSGreg Roachuse Illuminate\Support\Collection;
337c7d1e03SGreg Roach
3468ab58c3SGreg Roachuse function array_diff;
35f8505e4aSGreg Roachuse function array_filter;
3668ab58c3SGreg Roachuse function array_keys;
377c7d1e03SGreg Roachuse function array_merge;
38f8505e4aSGreg Roachuse function array_shift;
3968ab58c3SGreg Roachuse function array_slice;
40f8505e4aSGreg Roachuse function array_values;
417c7d1e03SGreg Roachuse function assert;
427c7d1e03SGreg Roachuse function count;
43f8505e4aSGreg Roachuse function explode;
44f8505e4aSGreg Roachuse function implode;
45f8505e4aSGreg Roachuse function max;
46f8505e4aSGreg Roachuse function preg_replace;
47f8505e4aSGreg Roachuse function preg_split;
48a6081838SGreg Roachuse function str_ends_with;
49f8505e4aSGreg Roachuse function str_repeat;
507c7d1e03SGreg Roachuse function str_replace;
516518218eSGreg Roachuse function str_starts_with;
52f8505e4aSGreg Roachuse function substr_count;
536102047cSGreg Roachuse function trim;
547c7d1e03SGreg Roach
559c7bc1e3SGreg Roachuse const ARRAY_FILTER_USE_BOTH;
56f8505e4aSGreg Roachuse const ARRAY_FILTER_USE_KEY;
57f8505e4aSGreg Roachuse const PHP_INT_MAX;
58f8505e4aSGreg Roach
597c7d1e03SGreg Roach/**
607c7d1e03SGreg Roach * Utilities to edit/save GEDCOM data.
617c7d1e03SGreg Roach */
627c7d1e03SGreg Roachclass GedcomEditService
637c7d1e03SGreg Roach{
647c7d1e03SGreg Roach    /**
656102047cSGreg Roach     * @param Tree $tree
666102047cSGreg Roach     *
6736779af1SGreg Roach     * @return Collection<int,Fact>
686102047cSGreg Roach     */
696102047cSGreg Roach    public function newFamilyFacts(Tree $tree): Collection
706102047cSGreg Roach    {
716102047cSGreg Roach        $dummy = Registry::familyFactory()->new('', '0 @@ FAM', null, $tree);
72eed04cf9SJonathan Jaubart        $tags  = (new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FAMFACTS'))))
73a2fcce95SGreg Roach            ->filter(static fn (string $tag): bool => $tag !== '');
746102047cSGreg Roach        $facts = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag));
756102047cSGreg Roach
766102047cSGreg Roach        return Fact::sortFacts($facts);
776102047cSGreg Roach    }
786102047cSGreg Roach
796102047cSGreg Roach    /**
806102047cSGreg Roach     * @param Tree          $tree
816102047cSGreg Roach     * @param string        $sex
82ddcf848dSGreg Roach     * @param array<string> $names
836102047cSGreg Roach     *
8436779af1SGreg Roach     * @return Collection<int,Fact>
856102047cSGreg Roach     */
866102047cSGreg Roach    public function newIndividualFacts(Tree $tree, string $sex, array $names): Collection
876102047cSGreg Roach    {
886102047cSGreg Roach        $dummy      = Registry::individualFactory()->new('', '0 @@ INDI', null, $tree);
89eed04cf9SJonathan Jaubart        $tags       = (new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FACTS'))))
90a2fcce95SGreg Roach            ->filter(static fn (string $tag): bool => $tag !== '');
916102047cSGreg Roach        $facts      = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag));
926102047cSGreg Roach        $sex_fact   = new Collection([new Fact('1 SEX ' . $sex, $dummy, '')]);
936102047cSGreg Roach        $name_facts = Collection::make($names)->map(static fn (string $gedcom): Fact => new Fact($gedcom, $dummy, ''));
946102047cSGreg Roach
956102047cSGreg Roach        return $sex_fact->concat($name_facts)->concat(Fact::sortFacts($facts));
966102047cSGreg Roach    }
976102047cSGreg Roach
986102047cSGreg Roach    /**
996102047cSGreg Roach     * @param GedcomRecord $record
1006102047cSGreg Roach     * @param string       $tag
1016102047cSGreg Roach     *
1026102047cSGreg Roach     * @return Fact
1036102047cSGreg Roach     */
1046102047cSGreg Roach    private function createNewFact(GedcomRecord $record, string $tag): Fact
1056102047cSGreg Roach    {
1066102047cSGreg Roach        $element = Registry::elementFactory()->make($record->tag() . ':' . $tag);
1076102047cSGreg Roach        $default = $element->default($record->tree());
1086102047cSGreg Roach        $gedcom  = trim('1 ' . $tag . ' ' . $default);
1096102047cSGreg Roach
1106102047cSGreg Roach        return new Fact($gedcom, $record, '');
1116102047cSGreg Roach    }
1126102047cSGreg Roach
1136102047cSGreg Roach    /**
114c2ed51d1SGreg Roach     * Reassemble edited GEDCOM fields into a GEDCOM fact/event string.
115c2ed51d1SGreg Roach     *
116c2ed51d1SGreg Roach     * @param string        $record_type
117c2ed51d1SGreg Roach     * @param array<string> $levels
118c2ed51d1SGreg Roach     * @param array<string> $tags
119c2ed51d1SGreg Roach     * @param array<string> $values
120*e5766395SGreg Roach     * @param bool          $append Are we appending to a level 0 record, or replacing a level 1 record?
121c2ed51d1SGreg Roach     *
122c2ed51d1SGreg Roach     * @return string
123c2ed51d1SGreg Roach     */
124b389d323SGreg Roach    public function editLinesToGedcom(string $record_type, array $levels, array $tags, array $values, bool $append = true): string
125c2ed51d1SGreg Roach    {
126c2ed51d1SGreg Roach        // Assert all arrays are the same size.
127c2ed51d1SGreg Roach        $count = count($levels);
128c2ed51d1SGreg Roach        assert($count > 0);
129c2ed51d1SGreg Roach        assert(count($tags) === $count);
130c2ed51d1SGreg Roach        assert(count($values) === $count);
131c2ed51d1SGreg Roach
132c2ed51d1SGreg Roach        $gedcom_lines = [];
133c2ed51d1SGreg Roach        $hierarchy    = [$record_type];
134c2ed51d1SGreg Roach
135c2ed51d1SGreg Roach        for ($i = 0; $i < $count; $i++) {
136c2ed51d1SGreg Roach            $hierarchy[$levels[$i]] = $tags[$i];
137c2ed51d1SGreg Roach
138c2ed51d1SGreg Roach            $full_tag   = implode(':', array_slice($hierarchy, 0, 1 + (int) $levels[$i]));
139c2ed51d1SGreg Roach            $element    = Registry::elementFactory()->make($full_tag);
140c2ed51d1SGreg Roach            $values[$i] = $element->canonical($values[$i]);
141c2ed51d1SGreg Roach
142c2ed51d1SGreg Roach            // If "1 FACT Y" has a DATE or PLAC, then delete the value of Y
143c2ed51d1SGreg Roach            if ($levels[$i] === '1' && $values[$i] === 'Y') {
144c2ed51d1SGreg Roach                for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; ++$j) {
145c2ed51d1SGreg Roach                    if ($levels[$j] === '2' && ($tags[$j] === 'DATE' || $tags[$j] === 'PLAC') && $values[$j] !== '') {
146c2ed51d1SGreg Roach                        $values[$i] = '';
147c2ed51d1SGreg Roach                        break;
148c2ed51d1SGreg Roach                    }
149c2ed51d1SGreg Roach                }
150c2ed51d1SGreg Roach            }
151c2ed51d1SGreg Roach
152cefd719cSGreg Roach            // Find the next tag at the same level.  Check if any child tags have values.
153cb62cb3cSGreg Roach            $children_with_values = false;
154cb62cb3cSGreg Roach            for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; $j++) {
155cb62cb3cSGreg Roach                if ($values[$j] !== '') {
156cb62cb3cSGreg Roach                    $children_with_values = true;
157cb62cb3cSGreg Roach                }
158c2ed51d1SGreg Roach            }
159c2ed51d1SGreg Roach
160cb62cb3cSGreg Roach            if ($values[$i] !== '' || $children_with_values  && !$element instanceof AbstractXrefElement) {
161c2ed51d1SGreg Roach                if ($values[$i] === '') {
162c2ed51d1SGreg Roach                    $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i];
163c2ed51d1SGreg Roach                } else {
164cb62cb3cSGreg Roach                    // We use CONC for editing NOTE records.
165f7c88e25SGreg Roach                    if ($tags[$i] === 'CONC') {
166f7c88e25SGreg Roach                        $next_level = (int) $levels[$i];
167f7c88e25SGreg Roach                    } else {
168c2ed51d1SGreg Roach                        $next_level = 1 + (int) $levels[$i];
169f7c88e25SGreg Roach                    }
170c2ed51d1SGreg Roach
171c2ed51d1SGreg Roach                    $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i] . ' ' . str_replace("\n", "\n" . $next_level . ' CONT ', $values[$i]);
172c2ed51d1SGreg Roach                }
173cb62cb3cSGreg Roach            } else {
174cefd719cSGreg Roach                $i = $j - 1;
175c2ed51d1SGreg Roach            }
176c2ed51d1SGreg Roach        }
177c2ed51d1SGreg Roach
178b389d323SGreg Roach        $gedcom = implode("\n", $gedcom_lines);
179b389d323SGreg Roach
180b389d323SGreg Roach        if ($append && $gedcom !== '') {
181b389d323SGreg Roach            $gedcom = "\n" . $gedcom;
182b389d323SGreg Roach        }
183b389d323SGreg Roach
184b389d323SGreg Roach        return $gedcom;
185c2ed51d1SGreg Roach    }
186e22e42f7SGreg Roach
187e22e42f7SGreg Roach    /**
188e22e42f7SGreg Roach     * Add blank lines, to allow a user to add/edit new values.
189e22e42f7SGreg Roach     *
190e22e42f7SGreg Roach     * @param Fact $fact
191e22e42f7SGreg Roach     * @param bool $include_hidden
192e22e42f7SGreg Roach     *
193e22e42f7SGreg Roach     * @return string
194e22e42f7SGreg Roach     */
195abdaad0dSGreg Roach    public function insertMissingFactSubtags(Fact $fact, bool $include_hidden): string
196e22e42f7SGreg Roach    {
1976518218eSGreg Roach        // Merge CONT records onto their parent line.
1986518218eSGreg Roach        $gedcom = preg_replace('/\n\d CONT ?/', "\r", $fact->gedcom());
1996518218eSGreg Roach
2006518218eSGreg Roach        return $this->insertMissingLevels($fact->record()->tree(), $fact->tag(), $gedcom, $include_hidden);
201f8505e4aSGreg Roach    }
202f8505e4aSGreg Roach
203f8505e4aSGreg Roach    /**
204f8505e4aSGreg Roach     * Add blank lines, to allow a user to add/edit new values.
205f8505e4aSGreg Roach     *
206f8505e4aSGreg Roach     * @param GedcomRecord $record
207f8505e4aSGreg Roach     * @param bool         $include_hidden
208f8505e4aSGreg Roach     *
209f8505e4aSGreg Roach     * @return string
210f8505e4aSGreg Roach     */
211f8505e4aSGreg Roach    public function insertMissingRecordSubtags(GedcomRecord $record, bool $include_hidden): string
212f8505e4aSGreg Roach    {
2136518218eSGreg Roach        // Merge CONT records onto their parent line.
2146518218eSGreg Roach        $gedcom = preg_replace('/\n\d CONT ?/', "\r", $record->gedcom());
2156518218eSGreg Roach
2166518218eSGreg Roach        $gedcom = $this->insertMissingLevels($record->tree(), $record->tag(), $gedcom, $include_hidden);
217f8505e4aSGreg Roach
218f8505e4aSGreg Roach        // NOTE records have data at level 0.  Move it to 1 CONC.
219abdaad0dSGreg Roach        if ($record instanceof Note) {
220f8505e4aSGreg Roach            return preg_replace('/^0 @[^@]+@ NOTE/', '1 CONC', $gedcom);
221f8505e4aSGreg Roach        }
222f8505e4aSGreg Roach
223f8505e4aSGreg Roach        return preg_replace('/^0.*\n/', '', $gedcom);
224f8505e4aSGreg Roach    }
225f8505e4aSGreg Roach
226f8505e4aSGreg Roach    /**
227abdaad0dSGreg Roach     * List of facts/events to add to families and individuals.
228abdaad0dSGreg Roach     *
229abdaad0dSGreg Roach     * @param Family|Individual $record
230abdaad0dSGreg Roach     * @param bool              $include_hidden
231abdaad0dSGreg Roach     *
232abdaad0dSGreg Roach     * @return array<string>
233abdaad0dSGreg Roach     */
23445ebc6c6SGreg Roach    public function factsToAdd(Family|Individual $record, bool $include_hidden): array
235abdaad0dSGreg Roach    {
236abdaad0dSGreg Roach        $subtags = Registry::elementFactory()->make($record->tag())->subtags();
237abdaad0dSGreg Roach
23805babb96SGreg Roach        $subtags = array_filter($subtags, static fn (string $v, string $k) => !str_ends_with($v, ':1') || $record->facts([$k])->isEmpty(), ARRAY_FILTER_USE_BOTH);
2399c7bc1e3SGreg Roach
2409c7bc1e3SGreg Roach        $subtags = array_keys($subtags);
2419c7bc1e3SGreg Roach
242686ef499SGreg Roach        // Don't include facts/events that we have hidden in the control panel.
2437c3bf6b0SGreg Roach        $subtags = array_filter($subtags, fn (string $subtag): bool => !$this->isHiddenTag($record->tag() . ':' . $subtag));
2447c3bf6b0SGreg Roach
245abdaad0dSGreg Roach        if (!$include_hidden) {
246abdaad0dSGreg Roach            $fn_hidden = fn (string $t): bool => !$this->isHiddenTag($record->tag() . ':' . $t);
247abdaad0dSGreg Roach            $subtags   = array_filter($subtags, $fn_hidden);
248abdaad0dSGreg Roach        }
249abdaad0dSGreg Roach
25068ab58c3SGreg Roach        return array_diff($subtags, ['HUSB', 'WIFE', 'CHIL', 'FAMC', 'FAMS', 'CHAN']);
251abdaad0dSGreg Roach    }
252abdaad0dSGreg Roach
253abdaad0dSGreg Roach    /**
254f8505e4aSGreg Roach     * @param Tree   $tree
255f8505e4aSGreg Roach     * @param string $tag
256f8505e4aSGreg Roach     * @param string $gedcom
257f8505e4aSGreg Roach     * @param bool   $include_hidden
258f8505e4aSGreg Roach     *
259f8505e4aSGreg Roach     * @return string
260f8505e4aSGreg Roach     */
261f8505e4aSGreg Roach    protected function insertMissingLevels(Tree $tree, string $tag, string $gedcom, bool $include_hidden): string
262f8505e4aSGreg Roach    {
263f8505e4aSGreg Roach        $next_level = substr_count($tag, ':') + 1;
264f8505e4aSGreg Roach        $factory    = Registry::elementFactory();
265f8505e4aSGreg Roach        $subtags    = $factory->make($tag)->subtags();
266f8505e4aSGreg Roach
267f8505e4aSGreg Roach        // The first part is level N.  The remainder are level N+1.
268f8505e4aSGreg Roach        $parts  = preg_split('/\n(?=' . $next_level . ')/', $gedcom);
2694502a888SGreg Roach        $return = array_shift($parts) ?? '';
270f8505e4aSGreg Roach
271f8505e4aSGreg Roach        foreach ($subtags as $subtag => $occurrences) {
272a6081838SGreg Roach            $hidden = str_ends_with($occurrences, ':?') || $this->isHiddenTag($tag . ':' . $subtag);
273a6081838SGreg Roach
274a6081838SGreg Roach            if (!$include_hidden && $hidden) {
275f8505e4aSGreg Roach                continue;
276f8505e4aSGreg Roach            }
277f8505e4aSGreg Roach
278f8505e4aSGreg Roach            [$min, $max] = explode(':', $occurrences);
279f8505e4aSGreg Roach
280f8505e4aSGreg Roach            $min = (int) $min;
281f8505e4aSGreg Roach
282f8505e4aSGreg Roach            if ($max === 'M') {
283f8505e4aSGreg Roach                $max = PHP_INT_MAX;
284f8505e4aSGreg Roach            } else {
285f8505e4aSGreg Roach                $max = (int) $max;
286f8505e4aSGreg Roach            }
287f8505e4aSGreg Roach
288f8505e4aSGreg Roach            $count = 0;
289f8505e4aSGreg Roach
290f8505e4aSGreg Roach            // Add expected subtags in our preferred order.
291f8505e4aSGreg Roach            foreach ($parts as $n => $part) {
292f8505e4aSGreg Roach                if (str_starts_with($part, $next_level . ' ' . $subtag)) {
293f8505e4aSGreg Roach                    $return .= "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $part, $include_hidden);
294f8505e4aSGreg Roach                    $count++;
295f8505e4aSGreg Roach                    unset($parts[$n]);
296f8505e4aSGreg Roach                }
297f8505e4aSGreg Roach            }
298f8505e4aSGreg Roach
299f8505e4aSGreg Roach            // Allowed to have more of this subtag?
300f8505e4aSGreg Roach            if ($count < $max) {
301f8505e4aSGreg Roach                // Create a new one.
302f8505e4aSGreg Roach                $gedcom  = $next_level . ' ' . $subtag;
303f8505e4aSGreg Roach                $default = $factory->make($tag . ':' . $subtag)->default($tree);
304f8505e4aSGreg Roach                if ($default !== '') {
305f8505e4aSGreg Roach                    $gedcom .= ' ' . $default;
306f8505e4aSGreg Roach                }
307f8505e4aSGreg Roach
308f8505e4aSGreg Roach                $number_to_add = max(1, $min - $count);
309f8505e4aSGreg Roach                $gedcom_to_add = "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $gedcom, $include_hidden);
310f8505e4aSGreg Roach
311f8505e4aSGreg Roach                $return .= str_repeat($gedcom_to_add, $number_to_add);
312f8505e4aSGreg Roach            }
313f8505e4aSGreg Roach        }
314f8505e4aSGreg Roach
315f8505e4aSGreg Roach        // Now add any unexpected/existing data.
316f8505e4aSGreg Roach        if ($parts !== []) {
317f8505e4aSGreg Roach            $return .= "\n" . implode("\n", $parts);
318f8505e4aSGreg Roach        }
319f8505e4aSGreg Roach
320f8505e4aSGreg Roach        return $return;
321f8505e4aSGreg Roach    }
322f8505e4aSGreg Roach
323f8505e4aSGreg Roach    /**
324f8505e4aSGreg Roach     * List of tags to exclude when creating new data.
325f8505e4aSGreg Roach     *
326f8505e4aSGreg Roach     * @param string $tag
327f8505e4aSGreg Roach     *
328f8505e4aSGreg Roach     * @return bool
329f8505e4aSGreg Roach     */
330f8505e4aSGreg Roach    private function isHiddenTag(string $tag): bool
331f8505e4aSGreg Roach    {
332a9da2374SGreg Roach        // Function to filter hidden tags.
33305babb96SGreg Roach        $fn_hide = static fn (string $x): bool => (bool) Site::getPreference('HIDE_' . $x);
334a9da2374SGreg Roach
335a9da2374SGreg Roach        $preferences = array_filter(Gedcom::HIDDEN_TAGS, $fn_hide, ARRAY_FILTER_USE_KEY);
336f8505e4aSGreg Roach        $preferences = array_values($preferences);
337f8505e4aSGreg Roach        $hidden_tags = array_merge(...$preferences);
338f8505e4aSGreg Roach
339f8505e4aSGreg Roach        foreach ($hidden_tags as $hidden_tag) {
340f8505e4aSGreg Roach            if (str_contains($tag, $hidden_tag)) {
341f8505e4aSGreg Roach                return true;
342f8505e4aSGreg Roach            }
343f8505e4aSGreg Roach        }
344f8505e4aSGreg Roach
345f8505e4aSGreg Roach        return false;
346e22e42f7SGreg Roach    }
3477c7d1e03SGreg Roach}
348