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