xref: /webtrees/app/Services/GedcomEditService.php (revision 6518218e5f66e3ad9d8b53c40e26d3cfd766e66b)
17c7d1e03SGreg Roach<?php
27c7d1e03SGreg Roach
37c7d1e03SGreg Roach/**
47c7d1e03SGreg Roach * webtrees: online genealogy
55bfc6897SGreg Roach * Copyright (C) 2022 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;
51*6518218eSGreg 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);
726102047cSGreg Roach        $tags  = new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FAMFACTS')));
736102047cSGreg Roach        $facts = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag));
746102047cSGreg Roach
756102047cSGreg Roach        return Fact::sortFacts($facts);
766102047cSGreg Roach    }
776102047cSGreg Roach
786102047cSGreg Roach    /**
796102047cSGreg Roach     * @param Tree          $tree
806102047cSGreg Roach     * @param string        $sex
81ddcf848dSGreg Roach     * @param array<string> $names
826102047cSGreg Roach     *
8336779af1SGreg Roach     * @return Collection<int,Fact>
846102047cSGreg Roach     */
856102047cSGreg Roach    public function newIndividualFacts(Tree $tree, string $sex, array $names): Collection
866102047cSGreg Roach    {
876102047cSGreg Roach        $dummy      = Registry::individualFactory()->new('', '0 @@ INDI', null, $tree);
886102047cSGreg Roach        $tags       = new Collection(explode(',', $tree->getPreference('QUICK_REQUIRED_FACTS')));
896102047cSGreg Roach        $facts      = $tags->map(fn (string $tag): Fact => $this->createNewFact($dummy, $tag));
906102047cSGreg Roach        $sex_fact   = new Collection([new Fact('1 SEX ' . $sex, $dummy, '')]);
916102047cSGreg Roach        $name_facts = Collection::make($names)->map(static fn (string $gedcom): Fact => new Fact($gedcom, $dummy, ''));
926102047cSGreg Roach
936102047cSGreg Roach        return $sex_fact->concat($name_facts)->concat(Fact::sortFacts($facts));
946102047cSGreg Roach    }
956102047cSGreg Roach
966102047cSGreg Roach    /**
976102047cSGreg Roach     * @param GedcomRecord $record
986102047cSGreg Roach     * @param string       $tag
996102047cSGreg Roach     *
1006102047cSGreg Roach     * @return Fact
1016102047cSGreg Roach     */
1026102047cSGreg Roach    private function createNewFact(GedcomRecord $record, string $tag): Fact
1036102047cSGreg Roach    {
1046102047cSGreg Roach        $element = Registry::elementFactory()->make($record->tag() . ':' . $tag);
1056102047cSGreg Roach        $default = $element->default($record->tree());
1066102047cSGreg Roach        $gedcom  = trim('1 ' . $tag . ' ' . $default);
1076102047cSGreg Roach
1086102047cSGreg Roach        return new Fact($gedcom, $record, '');
1096102047cSGreg Roach    }
1106102047cSGreg Roach
1116102047cSGreg Roach    /**
112c2ed51d1SGreg Roach     * Reassemble edited GEDCOM fields into a GEDCOM fact/event string.
113c2ed51d1SGreg Roach     *
114c2ed51d1SGreg Roach     * @param string        $record_type
115c2ed51d1SGreg Roach     * @param array<string> $levels
116c2ed51d1SGreg Roach     * @param array<string> $tags
117c2ed51d1SGreg Roach     * @param array<string> $values
118c2ed51d1SGreg Roach     *
119c2ed51d1SGreg Roach     * @return string
120c2ed51d1SGreg Roach     */
121c2ed51d1SGreg Roach    public function editLinesToGedcom(string $record_type, array $levels, array $tags, array $values): string
122c2ed51d1SGreg Roach    {
123c2ed51d1SGreg Roach        // Assert all arrays are the same size.
124c2ed51d1SGreg Roach        $count = count($levels);
125c2ed51d1SGreg Roach        assert($count > 0);
126c2ed51d1SGreg Roach        assert(count($tags) === $count);
127c2ed51d1SGreg Roach        assert(count($values) === $count);
128c2ed51d1SGreg Roach
129c2ed51d1SGreg Roach        $gedcom_lines = [];
130c2ed51d1SGreg Roach        $hierarchy    = [$record_type];
131c2ed51d1SGreg Roach
132c2ed51d1SGreg Roach        for ($i = 0; $i < $count; $i++) {
133c2ed51d1SGreg Roach            $hierarchy[$levels[$i]] = $tags[$i];
134c2ed51d1SGreg Roach
135c2ed51d1SGreg Roach            $full_tag   = implode(':', array_slice($hierarchy, 0, 1 + (int) $levels[$i]));
136c2ed51d1SGreg Roach            $element    = Registry::elementFactory()->make($full_tag);
137c2ed51d1SGreg Roach            $values[$i] = $element->canonical($values[$i]);
138c2ed51d1SGreg Roach
139c2ed51d1SGreg Roach            // If "1 FACT Y" has a DATE or PLAC, then delete the value of Y
140c2ed51d1SGreg Roach            if ($levels[$i] === '1' && $values[$i] === 'Y') {
141c2ed51d1SGreg Roach                for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; ++$j) {
142c2ed51d1SGreg Roach                    if ($levels[$j] === '2' && ($tags[$j] === 'DATE' || $tags[$j] === 'PLAC') && $values[$j] !== '') {
143c2ed51d1SGreg Roach                        $values[$i] = '';
144c2ed51d1SGreg Roach                        break;
145c2ed51d1SGreg Roach                    }
146c2ed51d1SGreg Roach                }
147c2ed51d1SGreg Roach            }
148c2ed51d1SGreg Roach
149cefd719cSGreg Roach            // Find the next tag at the same level.  Check if any child tags have values.
150cb62cb3cSGreg Roach            $children_with_values = false;
151cb62cb3cSGreg Roach            for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; $j++) {
152cb62cb3cSGreg Roach                if ($values[$j] !== '') {
153cb62cb3cSGreg Roach                    $children_with_values = true;
154cb62cb3cSGreg Roach                }
155c2ed51d1SGreg Roach            }
156c2ed51d1SGreg Roach
157cb62cb3cSGreg Roach            if ($values[$i] !== '' || $children_with_values  && !$element instanceof AbstractXrefElement) {
158c2ed51d1SGreg Roach                if ($values[$i] === '') {
159c2ed51d1SGreg Roach                    $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i];
160c2ed51d1SGreg Roach                } else {
161cb62cb3cSGreg Roach                    // We use CONC for editing NOTE records.
162f7c88e25SGreg Roach                    if ($tags[$i] === 'CONC') {
163f7c88e25SGreg Roach                        $next_level = (int) $levels[$i];
164f7c88e25SGreg Roach                    } else {
165c2ed51d1SGreg Roach                        $next_level = 1 + (int) $levels[$i];
166f7c88e25SGreg Roach                    }
167c2ed51d1SGreg Roach
168c2ed51d1SGreg Roach                    $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i] . ' ' . str_replace("\n", "\n" . $next_level . ' CONT ', $values[$i]);
169c2ed51d1SGreg Roach                }
170cb62cb3cSGreg Roach            } else {
171cefd719cSGreg Roach                $i = $j - 1;
172c2ed51d1SGreg Roach            }
173c2ed51d1SGreg Roach        }
174c2ed51d1SGreg Roach
175c2ed51d1SGreg Roach        return implode("\n", $gedcom_lines);
176c2ed51d1SGreg Roach    }
177e22e42f7SGreg Roach
178e22e42f7SGreg Roach    /**
179e22e42f7SGreg Roach     * Add blank lines, to allow a user to add/edit new values.
180e22e42f7SGreg Roach     *
181e22e42f7SGreg Roach     * @param Fact $fact
182e22e42f7SGreg Roach     * @param bool $include_hidden
183e22e42f7SGreg Roach     *
184e22e42f7SGreg Roach     * @return string
185e22e42f7SGreg Roach     */
186abdaad0dSGreg Roach    public function insertMissingFactSubtags(Fact $fact, bool $include_hidden): string
187e22e42f7SGreg Roach    {
188*6518218eSGreg Roach        // Merge CONT records onto their parent line.
189*6518218eSGreg Roach        $gedcom = preg_replace('/\n\d CONT ?/', "\r", $fact->gedcom());
190*6518218eSGreg Roach
191*6518218eSGreg Roach        return $this->insertMissingLevels($fact->record()->tree(), $fact->tag(), $gedcom, $include_hidden);
192f8505e4aSGreg Roach    }
193f8505e4aSGreg Roach
194f8505e4aSGreg Roach    /**
195f8505e4aSGreg Roach     * Add blank lines, to allow a user to add/edit new values.
196f8505e4aSGreg Roach     *
197f8505e4aSGreg Roach     * @param GedcomRecord $record
198f8505e4aSGreg Roach     * @param bool         $include_hidden
199f8505e4aSGreg Roach     *
200f8505e4aSGreg Roach     * @return string
201f8505e4aSGreg Roach     */
202f8505e4aSGreg Roach    public function insertMissingRecordSubtags(GedcomRecord $record, bool $include_hidden): string
203f8505e4aSGreg Roach    {
204*6518218eSGreg Roach        // Merge CONT records onto their parent line.
205*6518218eSGreg Roach        $gedcom = preg_replace('/\n\d CONT ?/', "\r", $record->gedcom());
206*6518218eSGreg Roach
207*6518218eSGreg Roach        $gedcom = $this->insertMissingLevels($record->tree(), $record->tag(), $gedcom, $include_hidden);
208f8505e4aSGreg Roach
209f8505e4aSGreg Roach        // NOTE records have data at level 0.  Move it to 1 CONC.
210abdaad0dSGreg Roach        if ($record instanceof Note) {
211f8505e4aSGreg Roach            return preg_replace('/^0 @[^@]+@ NOTE/', '1 CONC', $gedcom);
212f8505e4aSGreg Roach        }
213f8505e4aSGreg Roach
214f8505e4aSGreg Roach        return preg_replace('/^0.*\n/', '', $gedcom);
215f8505e4aSGreg Roach    }
216f8505e4aSGreg Roach
217f8505e4aSGreg Roach    /**
218abdaad0dSGreg Roach     * List of facts/events to add to families and individuals.
219abdaad0dSGreg Roach     *
220abdaad0dSGreg Roach     * @param Family|Individual $record
221abdaad0dSGreg Roach     * @param bool              $include_hidden
222abdaad0dSGreg Roach     *
223abdaad0dSGreg Roach     * @return array<string>
224abdaad0dSGreg Roach     */
225abdaad0dSGreg Roach    public function factsToAdd(GedcomRecord $record, bool $include_hidden): array
226abdaad0dSGreg Roach    {
227abdaad0dSGreg Roach        $subtags = Registry::elementFactory()->make($record->tag())->subtags();
228abdaad0dSGreg Roach
22905babb96SGreg Roach        $subtags = array_filter($subtags, static fn (string $v, string $k) => !str_ends_with($v, ':1') || $record->facts([$k])->isEmpty(), ARRAY_FILTER_USE_BOTH);
2309c7bc1e3SGreg Roach
2319c7bc1e3SGreg Roach        $subtags = array_keys($subtags);
2329c7bc1e3SGreg Roach
233686ef499SGreg Roach        // Don't include facts/events that we have hidden in the control panel.
2347c3bf6b0SGreg Roach        $subtags = array_filter($subtags, fn (string $subtag): bool => !$this->isHiddenTag($record->tag() . ':' . $subtag));
2357c3bf6b0SGreg Roach
236abdaad0dSGreg Roach        if (!$include_hidden) {
237abdaad0dSGreg Roach            $fn_hidden = fn (string $t): bool => !$this->isHiddenTag($record->tag() . ':' . $t);
238abdaad0dSGreg Roach            $subtags   = array_filter($subtags, $fn_hidden);
239abdaad0dSGreg Roach        }
240abdaad0dSGreg Roach
24168ab58c3SGreg Roach        return array_diff($subtags, ['HUSB', 'WIFE', 'CHIL', 'FAMC', 'FAMS', 'CHAN']);
242abdaad0dSGreg Roach    }
243abdaad0dSGreg Roach
244abdaad0dSGreg Roach    /**
245f8505e4aSGreg Roach     * @param Tree   $tree
246f8505e4aSGreg Roach     * @param string $tag
247f8505e4aSGreg Roach     * @param string $gedcom
248f8505e4aSGreg Roach     * @param bool   $include_hidden
249f8505e4aSGreg Roach     *
250f8505e4aSGreg Roach     * @return string
251f8505e4aSGreg Roach     */
252f8505e4aSGreg Roach    protected function insertMissingLevels(Tree $tree, string $tag, string $gedcom, bool $include_hidden): string
253f8505e4aSGreg Roach    {
254f8505e4aSGreg Roach        $next_level = substr_count($tag, ':') + 1;
255f8505e4aSGreg Roach        $factory    = Registry::elementFactory();
256f8505e4aSGreg Roach        $subtags    = $factory->make($tag)->subtags();
257f8505e4aSGreg Roach
258f8505e4aSGreg Roach        // The first part is level N.  The remainder are level N+1.
259f8505e4aSGreg Roach        $parts  = preg_split('/\n(?=' . $next_level . ')/', $gedcom);
2604502a888SGreg Roach        $return = array_shift($parts) ?? '';
261f8505e4aSGreg Roach
262f8505e4aSGreg Roach        foreach ($subtags as $subtag => $occurrences) {
263a6081838SGreg Roach            $hidden = str_ends_with($occurrences, ':?') || $this->isHiddenTag($tag . ':' . $subtag);
264a6081838SGreg Roach
265a6081838SGreg Roach            if (!$include_hidden && $hidden) {
266f8505e4aSGreg Roach                continue;
267f8505e4aSGreg Roach            }
268f8505e4aSGreg Roach
269f8505e4aSGreg Roach            [$min, $max] = explode(':', $occurrences);
270f8505e4aSGreg Roach
271f8505e4aSGreg Roach            $min = (int) $min;
272f8505e4aSGreg Roach
273f8505e4aSGreg Roach            if ($max === 'M') {
274f8505e4aSGreg Roach                $max = PHP_INT_MAX;
275f8505e4aSGreg Roach            } else {
276f8505e4aSGreg Roach                $max = (int) $max;
277f8505e4aSGreg Roach            }
278f8505e4aSGreg Roach
279f8505e4aSGreg Roach            $count = 0;
280f8505e4aSGreg Roach
281f8505e4aSGreg Roach            // Add expected subtags in our preferred order.
282f8505e4aSGreg Roach            foreach ($parts as $n => $part) {
283f8505e4aSGreg Roach                if (str_starts_with($part, $next_level . ' ' . $subtag)) {
284f8505e4aSGreg Roach                    $return .= "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $part, $include_hidden);
285f8505e4aSGreg Roach                    $count++;
286f8505e4aSGreg Roach                    unset($parts[$n]);
287f8505e4aSGreg Roach                }
288f8505e4aSGreg Roach            }
289f8505e4aSGreg Roach
290f8505e4aSGreg Roach            // Allowed to have more of this subtag?
291f8505e4aSGreg Roach            if ($count < $max) {
292f8505e4aSGreg Roach                // Create a new one.
293f8505e4aSGreg Roach                $gedcom  = $next_level . ' ' . $subtag;
294f8505e4aSGreg Roach                $default = $factory->make($tag . ':' . $subtag)->default($tree);
295f8505e4aSGreg Roach                if ($default !== '') {
296f8505e4aSGreg Roach                    $gedcom .= ' ' . $default;
297f8505e4aSGreg Roach                }
298f8505e4aSGreg Roach
299f8505e4aSGreg Roach                $number_to_add = max(1, $min - $count);
300f8505e4aSGreg Roach                $gedcom_to_add = "\n" . $this->insertMissingLevels($tree, $tag . ':' . $subtag, $gedcom, $include_hidden);
301f8505e4aSGreg Roach
302f8505e4aSGreg Roach                $return .= str_repeat($gedcom_to_add, $number_to_add);
303f8505e4aSGreg Roach            }
304f8505e4aSGreg Roach        }
305f8505e4aSGreg Roach
306f8505e4aSGreg Roach        // Now add any unexpected/existing data.
307f8505e4aSGreg Roach        if ($parts !== []) {
308f8505e4aSGreg Roach            $return .= "\n" . implode("\n", $parts);
309f8505e4aSGreg Roach        }
310f8505e4aSGreg Roach
311f8505e4aSGreg Roach        return $return;
312f8505e4aSGreg Roach    }
313f8505e4aSGreg Roach
314f8505e4aSGreg Roach    /**
315f8505e4aSGreg Roach     * List of tags to exclude when creating new data.
316f8505e4aSGreg Roach     *
317f8505e4aSGreg Roach     * @param string $tag
318f8505e4aSGreg Roach     *
319f8505e4aSGreg Roach     * @return bool
320f8505e4aSGreg Roach     */
321f8505e4aSGreg Roach    private function isHiddenTag(string $tag): bool
322f8505e4aSGreg Roach    {
323a9da2374SGreg Roach        // Function to filter hidden tags.
32405babb96SGreg Roach        $fn_hide = static fn (string $x): bool => (bool) Site::getPreference('HIDE_' . $x);
325a9da2374SGreg Roach
326a9da2374SGreg Roach        $preferences = array_filter(Gedcom::HIDDEN_TAGS, $fn_hide, ARRAY_FILTER_USE_KEY);
327f8505e4aSGreg Roach        $preferences = array_values($preferences);
328f8505e4aSGreg Roach        $hidden_tags = array_merge(...$preferences);
329f8505e4aSGreg Roach
330f8505e4aSGreg Roach        foreach ($hidden_tags as $hidden_tag) {
331f8505e4aSGreg Roach            if (str_contains($tag, $hidden_tag)) {
332f8505e4aSGreg Roach                return true;
333f8505e4aSGreg Roach            }
334f8505e4aSGreg Roach        }
335f8505e4aSGreg Roach
336f8505e4aSGreg Roach        return false;
337e22e42f7SGreg Roach    }
3387c7d1e03SGreg Roach}
339