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