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