xref: /webtrees/app/Services/GedcomEditService.php (revision e22e42f733f4ab630c81dc26a61e9a5470ca33de)
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\Fact;
23use Fisharebest\Webtrees\Gedcom;
24use Fisharebest\Webtrees\Registry;
25use Fisharebest\Webtrees\Tree;
26use Psr\Http\Message\ServerRequestInterface;
27
28use function array_merge;
29use function array_unique;
30use function assert;
31use function count;
32use function preg_match_all;
33use function str_replace;
34use function trim;
35
36/**
37 * Utilities to edit/save GEDCOM data.
38 */
39class GedcomEditService
40{
41    /** @var string[] */
42    public $glevels = [];
43
44    /** @var string[] */
45    public $tag = [];
46
47    /** @var string[] */
48    public $islink = [];
49
50    /** @var string[] */
51    public $text = [];
52
53    /** @var string[] */
54    protected $glevelsSOUR = [];
55
56    /** @var string[] */
57    protected $tagSOUR = [];
58
59    /** @var string[] */
60    protected $islinkSOUR = [];
61
62    /** @var string[] */
63    protected $textSOUR = [];
64
65    /** @var string[] */
66    protected $glevelsRest = [];
67
68    /** @var string[] */
69    protected $tagRest = [];
70
71    /** @var string[] */
72    protected $islinkRest = [];
73
74    /** @var string[] */
75    protected $textRest = [];
76
77    /**
78     * This function splits the $glevels, $tag, $islink, and $text arrays so that the
79     * entries associated with a SOUR record are separate from everything else.
80     *
81     * Input arrays:
82     * - $glevels[] - an array of the gedcom level for each line that was edited
83     * - $tag[] - an array of the tags for each gedcom line that was edited
84     * - $islink[] - an array of 1 or 0 values to indicate when the text is a link element
85     * - $text[] - an array of the text data for each line
86     *
87     * Output arrays:
88     * ** For the SOUR record:
89     * - $glevelsSOUR[] - an array of the gedcom level for each line that was edited
90     * - $tagSOUR[] - an array of the tags for each gedcom line that was edited
91     * - $islinkSOUR[] - an array of 1 or 0 values to indicate when the text is a link element
92     * - $textSOUR[] - an array of the text data for each line
93     * ** For the remaining records:
94     * - $glevelsRest[] - an array of the gedcom level for each line that was edited
95     * - $tagRest[] - an array of the tags for each gedcom line that was edited
96     * - $islinkRest[] - an array of 1 or 0 values to indicate when the text is a link element
97     * - $textRest[] - an array of the text data for each line
98     *
99     * @return void
100     */
101    public function splitSource(): void
102    {
103        $this->glevelsSOUR = [];
104        $this->tagSOUR     = [];
105        $this->islinkSOUR  = [];
106        $this->textSOUR    = [];
107
108        $this->glevelsRest = [];
109        $this->tagRest     = [];
110        $this->islinkRest  = [];
111        $this->textRest    = [];
112
113        $inSOUR    = false;
114        $levelSOUR = 0;
115
116        // Assume all arrays are the same size.
117        $count = count($this->glevels);
118
119        for ($i = 0; $i < $count; $i++) {
120            if ($inSOUR) {
121                if ($levelSOUR < $this->glevels[$i]) {
122                    $dest = 'S';
123                } else {
124                    $inSOUR = false;
125                    $dest   = 'R';
126                }
127            } elseif ($this->tag[$i] === 'SOUR') {
128                $inSOUR    = true;
129                $levelSOUR = $this->glevels[$i];
130                $dest      = 'S';
131            } else {
132                $dest = 'R';
133            }
134
135            if ($dest === 'S') {
136                $this->glevelsSOUR[] = $this->glevels[$i];
137                $this->tagSOUR[]     = $this->tag[$i];
138                $this->islinkSOUR[]  = $this->islink[$i];
139                $this->textSOUR[]    = $this->text[$i];
140            } else {
141                $this->glevelsRest[] = $this->glevels[$i];
142                $this->tagRest[]     = $this->tag[$i];
143                $this->islinkRest[]  = $this->islink[$i];
144                $this->textRest[]    = $this->text[$i];
145            }
146        }
147    }
148
149    /**
150     * Add new GEDCOM lines from the $xxxRest interface update arrays, which
151     * were produced by the splitSOUR() function.
152     * See the FunctionsEdit::handle_updatesges() function for details.
153     *
154     * @param string $inputRec
155     *
156     * @return string
157     */
158    public function updateRest(string $inputRec): string
159    {
160        if (count($this->tagRest) === 0) {
161            return $inputRec; // No update required
162        }
163
164        // Save original interface update arrays before replacing them with the xxxRest ones
165        $glevelsSave = $this->glevels;
166        $tagSave     = $this->tag;
167        $islinkSave  = $this->islink;
168        $textSave    = $this->text;
169
170        $this->glevels = $this->glevelsRest;
171        $this->tag     = $this->tagRest;
172        $this->islink  = $this->islinkRest;
173        $this->text    = $this->textRest;
174
175        $myRecord = $this->handleUpdates($inputRec, 'no'); // Now do the update
176
177        // Restore the original interface update arrays (just in case ...)
178        $this->glevels = $glevelsSave;
179        $this->tag     = $tagSave;
180        $this->islink  = $islinkSave;
181        $this->text    = $textSave;
182
183        return $myRecord;
184    }
185
186    /**
187     * Add new gedcom lines from interface update arrays
188     * The edit_interface and FunctionsEdit::add_simple_tag function produce the following
189     * arrays incoming from the $_POST form
190     * - $glevels[] - an array of the gedcom level for each line that was edited
191     * - $tag[] - an array of the tags for each gedcom line that was edited
192     * - $islink[] - an array of 1 or 0 values to tell whether the text is a link element and should be surrounded by @@
193     * - $text[] - an array of the text data for each line
194     * With these arrays you can recreate the gedcom lines like this
195     * <code>$glevel[0].' '.$tag[0].' '.$text[0]</code>
196     * There will be an index in each of these arrays for each line of the gedcom
197     * fact that is being edited.
198     * If the $text[] array is empty for the given line, then it means that the
199     * user removed that line during editing or that the line is supposed to be
200     * empty (1 DEAT, 1 BIRT) for example. To know if the line should be removed
201     * there is a section of code that looks ahead to the next lines to see if there
202     * are sub lines. For example we don't want to remove the 1 DEAT line if it has
203     * a 2 PLAC or 2 DATE line following it. If there are no sub lines, then the line
204     * can be safely removed.
205     *
206     * @param string $newged        the new gedcom record to add the lines to
207     * @param string $levelOverride Override GEDCOM level specified in $glevels[0]
208     *
209     * @return string The updated gedcom record
210     */
211    public function handleUpdates(string $newged, string $levelOverride = 'no'): string
212    {
213        if ($levelOverride === 'no') {
214            $levelAdjust = 0;
215        } else {
216            $levelAdjust = 1;
217        }
218
219        // Assert all arrays are the same size.
220        assert(count($this->glevels) === count($this->tag));
221        assert(count($this->glevels) === count($this->text));
222        assert(count($this->glevels) === count($this->islink));
223
224        $count = count($this->glevels);
225
226        for ($j = 0; $j < $count; $j++) {
227            // Look for empty SOUR reference with non-empty sub-records.
228            // This can happen when the SOUR entry is deleted but its sub-records
229            // were incorrectly left intact.
230            // The sub-records should be deleted.
231            if ($this->tag[$j] === 'SOUR' && ($this->text[$j] === '@@' || $this->text[$j] === '')) {
232                $this->text[$j] = '';
233                $k              = $j + 1;
234                while ($k < $count && $this->glevels[$k] > $this->glevels[$j]) {
235                    $this->text[$k] = '';
236                    $k++;
237                }
238            }
239
240            if (trim($this->text[$j]) !== '') {
241                $pass = true;
242            } else {
243                //-- for facts with empty values they must have sub records
244                //-- this section checks if they have subrecords
245                $k    = $j + 1;
246                $pass = false;
247                while ($k < $count && $this->glevels[$k] > $this->glevels[$j]) {
248                    if ($this->text[$k] !== '') {
249                        if ($this->tag[$j] !== 'OBJE' || $this->tag[$k] === 'FILE') {
250                            $pass = true;
251                            break;
252                        }
253                    }
254                    $k++;
255                }
256            }
257
258            //-- if the value is not empty or it has sub lines
259            //--- then write the line to the gedcom record
260            //-- we have to let some emtpy text lines pass through... (DEAT, BIRT, etc)
261            if ($pass) {
262                $newline = (int) $this->glevels[$j] + $levelAdjust . ' ' . $this->tag[$j];
263                if ($this->text[$j] !== '') {
264                    if ($this->islink[$j]) {
265                        $newline .= ' @' . trim($this->text[$j], '@') . '@';
266                    } else {
267                        $newline .= ' ' . $this->text[$j];
268                    }
269                }
270                $next_level = 1 + (int) $this->glevels[$j] + $levelAdjust;
271
272                $newged .= "\n" . str_replace("\n", "\n" . $next_level . ' CONT ', $newline);
273            }
274        }
275
276        return $newged;
277    }
278
279    /**
280     * Create a form to add a new fact.
281     *
282     * @param ServerRequestInterface $request
283     * @param Tree                   $tree
284     * @param string                 $fact
285     *
286     * @return string
287     */
288    public function addNewFact(ServerRequestInterface $request, Tree $tree, string $fact): string
289    {
290        $params = (array) $request->getParsedBody();
291
292        $FACT = $params[$fact];
293        $DATE = $params[$fact . '_DATE'] ?? '';
294        $PLAC = $params[$fact . '_PLAC'] ?? '';
295
296        if ($DATE !== '' || $PLAC !== '' || $FACT !== '' && $FACT !== 'Y') {
297            if ($FACT !== '' && $FACT !== 'Y') {
298                $gedrec = "\n1 " . $fact . ' ' . $FACT;
299            } else {
300                $gedrec = "\n1 " . $fact;
301            }
302            if ($DATE !== '') {
303                $gedrec .= "\n2 DATE " . $DATE;
304            }
305            if ($PLAC !== '') {
306                $gedrec .= "\n2 PLAC " . $PLAC;
307
308                if (preg_match_all('/(' . Gedcom::REGEX_TAG . ')/', $tree->getPreference('ADVANCED_PLAC_FACTS'), $match)) {
309                    foreach ($match[1] as $tag) {
310                        $TAG = $params[$fact . '_' . $tag];
311                        if ($TAG !== '') {
312                            $gedrec .= "\n3 " . $tag . ' ' . $TAG;
313                        }
314                    }
315                }
316                $LATI = $params[$fact . '_LATI'] ?? '';
317                $LONG = $params[$fact . '_LONG'] ?? '';
318                if ($LATI !== '' || $LONG !== '') {
319                    $gedrec .= "\n3 MAP\n4 LATI " . $LATI . "\n4 LONG " . $LONG;
320                }
321            }
322            if ((bool) ($params['SOUR_' . $fact] ?? false)) {
323                return $this->updateSource($gedrec, 'yes');
324            }
325
326            return $gedrec;
327        }
328
329        if ($FACT === 'Y') {
330            if ((bool) ($params['SOUR_' . $fact] ?? false)) {
331                return $this->updateSource("\n1 " . $fact . ' Y', 'yes');
332            }
333
334            return "\n1 " . $fact . ' Y';
335        }
336
337        return '';
338    }
339
340    /**
341     * Add new GEDCOM lines from the $xxxSOUR interface update arrays, which
342     * were produced by the splitSOUR() function.
343     * See the FunctionsEdit::handle_updatesges() function for details.
344     *
345     * @param string $inputRec
346     * @param string $levelOverride
347     *
348     * @return string
349     */
350    public function updateSource(string $inputRec, string $levelOverride = 'no'): string
351    {
352        if (count($this->tagSOUR) === 0) {
353            return $inputRec; // No update required
354        }
355
356        // Save original interface update arrays before replacing them with the xxxSOUR ones
357        $glevelsSave = $this->glevels;
358        $tagSave     = $this->tag;
359        $islinkSave  = $this->islink;
360        $textSave    = $this->text;
361
362        $this->glevels = $this->glevelsSOUR;
363        $this->tag     = $this->tagSOUR;
364        $this->islink  = $this->islinkSOUR;
365        $this->text    = $this->textSOUR;
366
367        $myRecord = $this->handleUpdates($inputRec, $levelOverride); // Now do the update
368
369        // Restore the original interface update arrays (just in case ...)
370        $this->glevels = $glevelsSave;
371        $this->tag     = $tagSave;
372        $this->islink  = $islinkSave;
373        $this->text    = $textSave;
374
375        return $myRecord;
376    }
377
378    /**
379     * Create a form to add a sex record.
380     *
381     * @param ServerRequestInterface $request
382     *
383     * @return string
384     */
385    public function addNewSex(ServerRequestInterface $request): string
386    {
387        $params = (array) $request->getParsedBody();
388
389        switch ($params['SEX']) {
390            case 'M':
391                return "\n1 SEX M";
392            case 'F':
393                return "\n1 SEX F";
394            default:
395                return "\n1 SEX U";
396        }
397    }
398
399    /**
400     * Assemble the pieces of a newly created record into gedcom
401     *
402     * @param ServerRequestInterface $request
403     * @param Tree                   $tree
404     *
405     * @return string
406     */
407    public function addNewName(ServerRequestInterface $request, Tree $tree): string
408    {
409        $params = (array) $request->getParsedBody();
410        $gedrec = "\n1 NAME " . $params['NAME'];
411
412        $tags = [
413            'NPFX',
414            'GIVN',
415            'SPFX',
416            'SURN',
417            'NSFX',
418            'NICK',
419        ];
420
421        if (preg_match_all('/(' . Gedcom::REGEX_TAG . ')/', $tree->getPreference('ADVANCED_NAME_FACTS'), $match)) {
422            $tags = array_merge($tags, $match[1]);
423        }
424
425        // Paternal and Polish and Lithuanian surname traditions can also create a _MARNM
426        $SURNAME_TRADITION = $tree->getPreference('SURNAME_TRADITION');
427        if ($SURNAME_TRADITION === 'paternal' || $SURNAME_TRADITION === 'polish' || $SURNAME_TRADITION === 'lithuanian') {
428            $tags[] = '_MARNM';
429        }
430
431        foreach (array_unique($tags) as $tag) {
432            $TAG = $params[$tag];
433
434            if ($TAG !== '') {
435                $gedrec .= "\n2 " . $tag . ' ' . $TAG;
436            }
437        }
438
439        return $gedrec;
440    }
441
442    /**
443     * Reassemble edited GEDCOM fields into a GEDCOM fact/event string.
444     *
445     * @param string        $record_type
446     * @param array<string> $levels
447     * @param array<string> $tags
448     * @param array<string> $values
449     *
450     * @return string
451     */
452    public function editLinesToGedcom(string $record_type, array $levels, array $tags, array $values): string
453    {
454        // Assert all arrays are the same size.
455        $count = count($levels);
456        assert($count > 0);
457        assert(count($tags) === $count);
458        assert(count($values) === $count);
459
460        $gedcom_lines = [];
461        $hierarchy    = [$record_type];
462
463        for ($i = 0; $i < $count; $i++) {
464            $hierarchy[$levels[$i]] = $tags[$i];
465
466            $full_tag   = implode(':', array_slice($hierarchy, 0, 1 + (int) $levels[$i]));
467            $element    = Registry::elementFactory()->make($full_tag);
468            $values[$i] = $element->canonical($values[$i]);
469
470            // If "1 FACT Y" has a DATE or PLAC, then delete the value of Y
471            if ($levels[$i] === '1' && $values[$i] === 'Y') {
472                for ($j = $i + 1; $j < $count && $levels[$j] > $levels[$i]; ++$j) {
473                    if ($levels[$j] === '2' && ($tags[$j] === 'DATE' || $tags[$j] === 'PLAC') && $values[$j] !== '') {
474                        $values[$i] = '';
475                        break;
476                    }
477                }
478            }
479
480            // Include this line if there is a value - or if there is a child record with a value.
481            $include = $values[$i] !== '';
482
483            for ($j = $i + 1; !$include && $j < $count && $levels[$j] > $levels[$i]; $j++) {
484                $include = $values[$j] !== '';
485            }
486
487            if ($include) {
488                if ($values[$i] === '') {
489                    $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i];
490                } else {
491                    if ($tags[$i] === 'CONC') {
492                        $next_level = (int) $levels[$i];
493                    } else {
494                        $next_level = 1 + (int) $levels[$i];
495                    }
496
497                    $gedcom_lines[] = $levels[$i] . ' ' . $tags[$i] . ' ' . str_replace("\n", "\n" . $next_level . ' CONT ', $values[$i]);
498                }
499            }
500        }
501
502        return implode("\n", $gedcom_lines);
503    }
504
505    /**
506     * Add blank lines, to allow a user to add/edit new values.
507     *
508     * @param Fact $fact
509     * @param bool $include_hidden
510     *
511     * @return string
512     */
513    public function insertMissingSubtags(Fact $fact, bool $include_hidden): string
514    {
515        return $fact->record()->insertMissingLevels($fact->tag(), $fact->gedcom(), $include_hidden);
516    }
517}
518