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