Completed
Pull Request — main (#3748)
by Jonathan
07:01
created

GedcomRecord   F

Complexity

Total Complexity 165

Size/Duplication

Total Lines 1331
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 165
eloc 459
dl 0
loc 1331
rs 2
c 0
b 0
f 0

58 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 1
A sortName() 0 6 1
A getAllEventDates() 0 10 3
A extractNames() 0 3 1
A tag() 0 5 1
B formatFirstMajorFact() 0 20 8
A gedcom() 0 3 1
A lastChangeComparator() 0 4 1
A linkedMedia() 0 15 1
B getSecondaryName() 0 19 7
A nameComparator() 0 16 4
A getAllEventPlaces() 0 12 4
A canEdit() 0 11 4
A linkedLocations() 0 16 1
A isPendingAddition() 0 3 1
A url() 0 6 1
A setPrimaryName() 0 4 1
A getFallBackName() 0 3 1
A linkedFamilies() 0 15 1
A accessFilter() 0 4 1
A formatListDetails() 0 3 1
A linkedNotes() 0 16 1
A createFact() 0 3 1
A linkedIndividuals() 0 15 1
A fullName() 0 9 2
A formatList() 0 8 1
A tree() 0 3 1
A slug() 0 15 3
A privatizeGedcom() 0 24 4
A getAllNames() 0 17 4
A alternateName() 0 9 3
A getPrimaryName() 0 21 5
A linkedSources() 0 15 1
A linkedRepositories() 0 16 1
A deleteFact() 0 3 1
A canShowName() 0 3 1
A lastChangeTimestamp() 0 22 4
A lastChangeUser() 0 14 3
B facts() 0 28 8
A __toString() 0 3 1
A isPendingDeletion() 0 3 1
A xref() 0 3 1
A invalidateInCache() 0 5 1
A insertMissingSubtags() 0 5 1
A addName() 0 10 1
B insertMissingLevels() 0 47 8
A removeLinks() 0 15 5
A deleteRecord() 0 21 3
A canShow() 0 15 2
A lock() 0 7 1
A canShowByType() 0 11 2
B extractNamesFromFacts() 0 16 9
A updateRecord() 0 40 4
B parseFacts() 0 30 8
D updateFact() 0 68 16
B canShowRecord() 0 36 9
A linkingRecords() 0 30 1
A createPrivateGedcomRecord() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like GedcomRecord often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use GedcomRecord, and based on these observations, apply Extract Interface, too.

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
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees;
21
22
use Closure;
23
use Exception;
24
use Fisharebest\Webtrees\Contracts\UserInterface;
25
use Fisharebest\Webtrees\Functions\FunctionsPrint;
26
use Fisharebest\Webtrees\Http\RequestHandlers\GedcomRecordPage;
27
use Fisharebest\Webtrees\Services\PendingChangesService;
28
use Illuminate\Database\Capsule\Manager as DB;
29
use Illuminate\Database\Query\Builder;
30
use Illuminate\Database\Query\Expression;
31
use Illuminate\Database\Query\JoinClause;
32
use Illuminate\Support\Collection;
33
use Throwable;
34
use Transliterator;
35
36
use function addcslashes;
37
use function app;
38
use function array_shift;
39
use function assert;
40
use function count;
41
use function date;
1 ignored issue
show
Bug introduced by
This use statement conflicts with another class in this namespace, Fisharebest\Webtrees\date. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
42
use function e;
43
use function explode;
44
use function implode;
45
use function in_array;
46
use function md5;
47
use function preg_match;
48
use function preg_match_all;
49
use function preg_replace;
50
use function preg_replace_callback;
51
use function preg_split;
52
use function route;
53
use function str_contains;
54
use function str_pad;
55
use function str_starts_with;
56
use function strip_tags;
57
use function strtoupper;
58
use function substr_count;
59
use function trim;
60
61
use const PHP_INT_MAX;
62
use const PREG_SET_ORDER;
63
use const STR_PAD_LEFT;
64
65
/**
66
 * A GEDCOM object.
67
 */
68
class GedcomRecord
69
{
70
    public const RECORD_TYPE = 'UNKNOWN';
71
72
    protected const ROUTE_NAME = GedcomRecordPage::class;
73
74
    /** @var string The record identifier */
75
    protected $xref;
76
77
    /** @var Tree  The family tree to which this record belongs */
78
    protected $tree;
79
80
    /** @var string  GEDCOM data (before any pending edits) */
81
    protected $gedcom;
82
83
    /** @var string|null  GEDCOM data (after any pending edits) */
84
    protected $pending;
85
86
    /** @var Fact[] facts extracted from $gedcom/$pending */
87
    protected $facts;
88
89
    /** @var string[][] All the names of this individual */
90
    protected $getAllNames;
91
92
    /** @var int|null Cached result */
93
    protected $getPrimaryName;
94
    /** @var int|null Cached result */
95
    protected $getSecondaryName;
96
97
    /**
98
     * Create a GedcomRecord object from raw GEDCOM data.
99
     *
100
     * @param string      $xref
101
     * @param string      $gedcom  an empty string for new/pending records
102
     * @param string|null $pending null for a record with no pending edits,
103
     *                             empty string for records with pending deletions
104
     * @param Tree        $tree
105
     */
106
    public function __construct(string $xref, string $gedcom, ?string $pending, Tree $tree)
107
    {
108
        $this->xref    = $xref;
109
        $this->gedcom  = $gedcom;
110
        $this->pending = $pending;
111
        $this->tree    = $tree;
112
113
        $this->parseFacts();
114
    }
115
116
    /**
117
     * A closure which will filter out private records.
118
     *
119
     * @return Closure
120
     */
121
    public static function accessFilter(): Closure
122
    {
123
        return static function (GedcomRecord $record): bool {
124
            return $record->canShow();
125
        };
126
    }
127
128
    /**
129
     * A closure which will compare records by name.
130
     *
131
     * @return Closure
132
     */
133
    public static function nameComparator(): Closure
134
    {
135
        return static function (GedcomRecord $x, GedcomRecord $y): int {
136
            if ($x->canShowName()) {
137
                if ($y->canShowName()) {
138
                    return I18N::strcasecmp($x->sortName(), $y->sortName());
139
                }
140
141
                return -1; // only $y is private
142
            }
143
144
            if ($y->canShowName()) {
145
                return 1; // only $x is private
146
            }
147
148
            return 0; // both $x and $y private
149
        };
150
    }
151
152
    /**
153
     * A closure which will compare records by change time.
154
     *
155
     * @param int $direction +1 to sort ascending, -1 to sort descending
156
     *
157
     * @return Closure
158
     */
159
    public static function lastChangeComparator(int $direction = 1): Closure
160
    {
161
        return static function (GedcomRecord $x, GedcomRecord $y) use ($direction): int {
162
            return $direction * ($x->lastChangeTimestamp() <=> $y->lastChangeTimestamp());
163
        };
164
    }
165
166
    /**
167
     * Get the GEDCOM tag for this record.
168
     *
169
     * @return string
170
     */
171
    public function tag(): string
172
    {
173
        preg_match('/^0 @[^@]*@ (\w+)/', $this->gedcom(), $match);
174
175
        return $match[1] ?? static::RECORD_TYPE;
176
    }
177
178
    /**
179
     * Get the XREF for this record
180
     *
181
     * @return string
182
     */
183
    public function xref(): string
184
    {
185
        return $this->xref;
186
    }
187
188
    /**
189
     * Get the tree to which this record belongs
190
     *
191
     * @return Tree
192
     */
193
    public function tree(): Tree
194
    {
195
        return $this->tree;
196
    }
197
198
    /**
199
     * Application code should access data via Fact objects.
200
     * This function exists to support old code.
201
     *
202
     * @return string
203
     */
204
    public function gedcom(): string
205
    {
206
        return $this->pending ?? $this->gedcom;
207
    }
208
209
    /**
210
     * Does this record have a pending change?
211
     *
212
     * @return bool
213
     */
214
    public function isPendingAddition(): bool
215
    {
216
        return $this->pending !== null;
217
    }
218
219
    /**
220
     * Does this record have a pending deletion?
221
     *
222
     * @return bool
223
     */
224
    public function isPendingDeletion(): bool
225
    {
226
        return $this->pending === '';
227
    }
228
229
    /**
230
     * Generate a "slug" to use in pretty URLs.
231
     *
232
     * @return string
233
     */
234
    public function slug(): string
235
    {
236
        $slug = strip_tags($this->fullName());
237
238
        try {
239
            $transliterator = Transliterator::create('Any-Latin;Latin-ASCII');
240
            $slug           = $transliterator->transliterate($slug);
241
        } catch (Throwable $ex) {
242
            // ext-intl not installed?
243
            // Transliteration algorithms not present in lib-icu?
244
        }
245
246
        $slug = preg_replace('/[^A-Za-z0-9]+/', '-', $slug);
247
248
        return trim($slug, '-') ?: '-';
249
    }
250
251
    /**
252
     * Generate a URL to this record.
253
     *
254
     * @return string
255
     */
256
    public function url(): string
257
    {
258
        return route(static::ROUTE_NAME, [
259
            'xref' => $this->xref(),
260
            'tree' => $this->tree->name(),
261
            'slug' => $this->slug(),
262
        ]);
263
    }
264
265
    /**
266
     * Can the details of this record be shown?
267
     *
268
     * @param int|null $access_level
269
     *
270
     * @return bool
271
     */
272
    public function canShow(int $access_level = null): bool
273
    {
274
        $access_level = $access_level ?? Auth::accessLevel($this->tree);
275
276
        // We use this value to bypass privacy checks. For example,
277
        // when downloading data or when calculating privacy itself.
278
        if ($access_level === Auth::PRIV_HIDE) {
279
            return true;
280
        }
281
282
        $cache_key = 'show-' . $this->xref . '-' . $this->tree->id() . '-' . $access_level;
283
284
        return Registry::cache()->array()->remember($cache_key, function () use ($access_level) {
285
            return $this->canShowRecord($access_level);
286
        }, null, ['gedrec-' . $this->tree->id() . '-' . $this->xref]);
287
    }
288
289
    /**
290
     * Can the name of this record be shown?
291
     *
292
     * @param int|null $access_level
293
     *
294
     * @return bool
295
     */
296
    public function canShowName(int $access_level = null): bool
297
    {
298
        return $this->canShow($access_level);
299
    }
300
301
    /**
302
     * Can we edit this record?
303
     *
304
     * @return bool
305
     */
306
    public function canEdit(): bool
307
    {
308
        if ($this->isPendingDeletion()) {
309
            return false;
310
        }
311
312
        if (Auth::isManager($this->tree)) {
313
            return true;
314
        }
315
316
        return Auth::isEditor($this->tree) && !str_contains($this->gedcom, "\n1 RESN locked");
317
    }
318
319
    /**
320
     * Remove private data from the raw gedcom record.
321
     * Return both the visible and invisible data. We need the invisible data when editing.
322
     *
323
     * @param int $access_level
324
     *
325
     * @return string
326
     */
327
    public function privatizeGedcom(int $access_level): string
328
    {
329
        if ($access_level === Auth::PRIV_HIDE) {
330
            // We may need the original record, for example when downloading a GEDCOM or clippings cart
331
            return $this->gedcom;
332
        }
333
334
        if ($this->canShow($access_level)) {
335
            // The record is not private, but the individual facts may be.
336
337
            // Include the entire first line (for NOTE records)
338
            [$gedrec] = explode("\n", $this->gedcom . $this->pending, 2);
339
340
            // Check each of the facts for access
341
            foreach ($this->facts([], false, $access_level) as $fact) {
342
                $gedrec .= "\n" . $fact->gedcom();
343
            }
344
345
            return $gedrec;
346
        }
347
348
        // We cannot display the details, but we may be able to display
349
        // limited data, such as links to other records.
350
        return $this->createPrivateGedcomRecord($access_level);
351
    }
352
353
    /**
354
     * Default for "other" object types
355
     *
356
     * @return void
357
     */
358
    public function extractNames(): void
359
    {
360
        $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
361
    }
362
363
    /**
364
     * Derived classes should redefine this function, otherwise the object will have no name
365
     *
366
     * @return array<array<string>>
367
     */
368
    public function getAllNames(): array
369
    {
370
        if ($this->getAllNames === null) {
371
            $this->getAllNames = [];
372
            if ($this->canShowName()) {
373
                // Ask the record to extract its names
374
                $this->extractNames();
375
                // No name found? Use a fallback.
376
                if ($this->getAllNames === []) {
377
                    $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
378
                }
379
            } else {
380
                $this->addName(static::RECORD_TYPE, I18N::translate('Private'), '');
381
            }
382
        }
383
384
        return $this->getAllNames;
385
    }
386
387
    /**
388
     * If this object has no name, what do we call it?
389
     *
390
     * @return string
391
     */
392
    public function getFallBackName(): string
393
    {
394
        return e($this->xref());
395
    }
396
397
    /**
398
     * Which of the (possibly several) names of this record is the primary one.
399
     *
400
     * @return int
401
     */
402
    public function getPrimaryName(): int
403
    {
404
        static $language_script;
405
406
        if ($language_script === null) {
407
            $language_script = $language_script ?? I18N::locale()->script()->code();
408
        }
409
410
        if ($this->getPrimaryName === null) {
411
            // Generally, the first name is the primary one....
412
            $this->getPrimaryName = 0;
413
            // ...except when the language/name use different character sets
414
            foreach ($this->getAllNames() as $n => $name) {
415
                if (I18N::textScript($name['sort']) === $language_script) {
416
                    $this->getPrimaryName = $n;
417
                    break;
418
                }
419
            }
420
        }
421
422
        return $this->getPrimaryName;
423
    }
424
425
    /**
426
     * Which of the (possibly several) names of this record is the secondary one.
427
     *
428
     * @return int
429
     */
430
    public function getSecondaryName(): int
431
    {
432
        if ($this->getSecondaryName === null) {
433
            // Generally, the primary and secondary names are the same
434
            $this->getSecondaryName = $this->getPrimaryName();
435
            // ....except when there are names with different character sets
436
            $all_names = $this->getAllNames();
437
            if (count($all_names) > 1) {
438
                $primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']);
439
                foreach ($all_names as $n => $name) {
440
                    if ($n !== $this->getPrimaryName() && $name['type'] !== '_MARNM' && I18N::textScript($name['sort']) !== $primary_script) {
441
                        $this->getSecondaryName = $n;
442
                        break;
443
                    }
444
                }
445
            }
446
        }
447
448
        return $this->getSecondaryName;
449
    }
450
451
    /**
452
     * Allow the choice of primary name to be overidden, e.g. in a search result
453
     *
454
     * @param int|null $n
455
     *
456
     * @return void
457
     */
458
    public function setPrimaryName(int $n = null): void
459
    {
460
        $this->getPrimaryName   = $n;
461
        $this->getSecondaryName = null;
462
    }
463
464
    /**
465
     * Allow native PHP functions such as array_unique() to work with objects
466
     *
467
     * @return string
468
     */
469
    public function __toString(): string
470
    {
471
        return $this->xref . '@' . $this->tree->id();
472
    }
473
474
    /**
475
     * /**
476
     * Get variants of the name
477
     *
478
     * @return string
479
     */
480
    public function fullName(): string
481
    {
482
        if ($this->canShowName()) {
483
            $tmp = $this->getAllNames();
484
485
            return $tmp[$this->getPrimaryName()]['full'];
486
        }
487
488
        return I18N::translate('Private');
489
    }
490
491
    /**
492
     * Get a sortable version of the name. Do not display this!
493
     *
494
     * @return string
495
     */
496
    public function sortName(): string
497
    {
498
        // The sortable name is never displayed, no need to call canShowName()
499
        $tmp = $this->getAllNames();
500
501
        return $tmp[$this->getPrimaryName()]['sort'];
502
    }
503
504
    /**
505
     * Get the full name in an alternative character set
506
     *
507
     * @return string|null
508
     */
509
    public function alternateName(): ?string
510
    {
511
        if ($this->canShowName() && $this->getPrimaryName() !== $this->getSecondaryName()) {
512
            $all_names = $this->getAllNames();
513
514
            return $all_names[$this->getSecondaryName()]['full'];
515
        }
516
517
        return null;
518
    }
519
520
    /**
521
     * Format this object for display in a list
522
     *
523
     * @return string
524
     */
525
    public function formatList(): string
526
    {
527
        $html = '<a href="' . e($this->url()) . '" class="list_item">';
528
        $html .= '<b>' . $this->fullName() . '</b>';
529
        $html .= $this->formatListDetails();
530
        $html .= '</a>';
531
532
        return $html;
533
    }
534
535
    /**
536
     * This function should be redefined in derived classes to show any major
537
     * identifying characteristics of this record.
538
     *
539
     * @return string
540
     */
541
    public function formatListDetails(): string
542
    {
543
        return '';
544
    }
545
546
    /**
547
     * Extract/format the first fact from a list of facts.
548
     *
549
     * @param string[] $facts
550
     * @param int      $style
551
     *
552
     * @return string
553
     */
554
    public function formatFirstMajorFact(array $facts, int $style): string
555
    {
556
        foreach ($this->facts($facts, true) as $event) {
557
            // Only display if it has a date or place (or both)
558
            if ($event->date()->isOK() && $event->place()->gedcomName() !== '') {
559
                $joiner = ' — ';
560
            } else {
561
                $joiner = '';
562
            }
563
            if ($event->date()->isOK() || $event->place()->gedcomName() !== '') {
564
                switch ($style) {
565
                    case 1:
566
                        return '<br><em>' . $event->label() . ' ' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</em>';
567
                    case 2:
568
                        return '<dl><dt class="label">' . $event->label() . '</dt><dd class="field">' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</dd></dl>';
569
                }
570
            }
571
        }
572
573
        return '';
574
    }
575
576
    /**
577
     * Find individuals linked to this record.
578
     *
579
     * @param string $link
580
     *
581
     * @return Collection<Individual>
582
     */
583
    public function linkedIndividuals(string $link): Collection
584
    {
585
        return DB::table('individuals')
586
            ->join('link', static function (JoinClause $join): void {
587
                $join
588
                    ->on('l_file', '=', 'i_file')
589
                    ->on('l_from', '=', 'i_id');
590
            })
591
            ->where('i_file', '=', $this->tree->id())
592
            ->where('l_type', '=', $link)
593
            ->where('l_to', '=', $this->xref)
594
            ->select(['individuals.*'])
595
            ->get()
596
            ->map(Registry::individualFactory()->mapper($this->tree))
597
            ->filter(self::accessFilter());
598
    }
599
600
    /**
601
     * Find families linked to this record.
602
     *
603
     * @param string $link
604
     *
605
     * @return Collection<Family>
606
     */
607
    public function linkedFamilies(string $link): Collection
608
    {
609
        return DB::table('families')
610
            ->join('link', static function (JoinClause $join): void {
611
                $join
612
                    ->on('l_file', '=', 'f_file')
613
                    ->on('l_from', '=', 'f_id');
614
            })
615
            ->where('f_file', '=', $this->tree->id())
616
            ->where('l_type', '=', $link)
617
            ->where('l_to', '=', $this->xref)
618
            ->select(['families.*'])
619
            ->get()
620
            ->map(Registry::familyFactory()->mapper($this->tree))
621
            ->filter(self::accessFilter());
622
    }
623
624
    /**
625
     * Find sources linked to this record.
626
     *
627
     * @param string $link
628
     *
629
     * @return Collection<Source>
630
     */
631
    public function linkedSources(string $link): Collection
632
    {
633
        return DB::table('sources')
634
            ->join('link', static function (JoinClause $join): void {
635
                $join
636
                    ->on('l_file', '=', 's_file')
637
                    ->on('l_from', '=', 's_id');
638
            })
639
            ->where('s_file', '=', $this->tree->id())
640
            ->where('l_type', '=', $link)
641
            ->where('l_to', '=', $this->xref)
642
            ->select(['sources.*'])
643
            ->get()
644
            ->map(Registry::sourceFactory()->mapper($this->tree))
645
            ->filter(self::accessFilter());
646
    }
647
648
    /**
649
     * Find media objects linked to this record.
650
     *
651
     * @param string $link
652
     *
653
     * @return Collection<Media>
654
     */
655
    public function linkedMedia(string $link): Collection
656
    {
657
        return DB::table('media')
658
            ->join('link', static function (JoinClause $join): void {
659
                $join
660
                    ->on('l_file', '=', 'm_file')
661
                    ->on('l_from', '=', 'm_id');
662
            })
663
            ->where('m_file', '=', $this->tree->id())
664
            ->where('l_type', '=', $link)
665
            ->where('l_to', '=', $this->xref)
666
            ->select(['media.*'])
667
            ->get()
668
            ->map(Registry::mediaFactory()->mapper($this->tree))
669
            ->filter(self::accessFilter());
670
    }
671
672
    /**
673
     * Find notes linked to this record.
674
     *
675
     * @param string $link
676
     *
677
     * @return Collection<Note>
678
     */
679
    public function linkedNotes(string $link): Collection
680
    {
681
        return DB::table('other')
682
            ->join('link', static function (JoinClause $join): void {
683
                $join
684
                    ->on('l_file', '=', 'o_file')
685
                    ->on('l_from', '=', 'o_id');
686
            })
687
            ->where('o_file', '=', $this->tree->id())
688
            ->where('o_type', '=', Note::RECORD_TYPE)
689
            ->where('l_type', '=', $link)
690
            ->where('l_to', '=', $this->xref)
691
            ->select(['other.*'])
692
            ->get()
693
            ->map(Registry::noteFactory()->mapper($this->tree))
694
            ->filter(self::accessFilter());
695
    }
696
697
    /**
698
     * Find repositories linked to this record.
699
     *
700
     * @param string $link
701
     *
702
     * @return Collection<Repository>
703
     */
704
    public function linkedRepositories(string $link): Collection
705
    {
706
        return DB::table('other')
707
            ->join('link', static function (JoinClause $join): void {
708
                $join
709
                    ->on('l_file', '=', 'o_file')
710
                    ->on('l_from', '=', 'o_id');
711
            })
712
            ->where('o_file', '=', $this->tree->id())
713
            ->where('o_type', '=', Repository::RECORD_TYPE)
714
            ->where('l_type', '=', $link)
715
            ->where('l_to', '=', $this->xref)
716
            ->select(['other.*'])
717
            ->get()
718
            ->map(Registry::repositoryFactory()->mapper($this->tree))
719
            ->filter(self::accessFilter());
720
    }
721
722
    /**
723
     * Find locations linked to this record.
724
     *
725
     * @param string $link
726
     *
727
     * @return Collection<Location>
728
     */
729
    public function linkedLocations(string $link): Collection
730
    {
731
        return DB::table('other')
732
            ->join('link', static function (JoinClause $join): void {
733
                $join
734
                    ->on('l_file', '=', 'o_file')
735
                    ->on('l_from', '=', 'o_id');
736
            })
737
            ->where('o_file', '=', $this->tree->id())
738
            ->where('o_type', '=', Location::RECORD_TYPE)
739
            ->where('l_type', '=', $link)
740
            ->where('l_to', '=', $this->xref)
741
            ->select(['other.*'])
742
            ->get()
743
            ->map(Registry::locationFactory()->mapper($this->tree))
744
            ->filter(self::accessFilter());
745
    }
746
747
    /**
748
     * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR).
749
     * This is used to display multiple events on the individual/family lists.
750
     * Multiple events can exist because of uncertainty in dates, dates in different
751
     * calendars, place-names in both latin and hebrew character sets, etc.
752
     * It also allows us to combine dates/places from different events in the summaries.
753
     *
754
     * @param string[] $events
755
     *
756
     * @return Date[]
757
     */
758
    public function getAllEventDates(array $events): array
759
    {
760
        $dates = [];
761
        foreach ($this->facts($events, false, null, true) as $event) {
762
            if ($event->date()->isOK()) {
763
                $dates[] = $event->date();
764
            }
765
        }
766
767
        return $dates;
768
    }
769
770
    /**
771
     * Get all the places for a particular type of event
772
     *
773
     * @param string[] $events
774
     *
775
     * @return Place[]
776
     */
777
    public function getAllEventPlaces(array $events): array
778
    {
779
        $places = [];
780
        foreach ($this->facts($events) as $event) {
781
            if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->gedcom(), $ged_places)) {
782
                foreach ($ged_places[1] as $ged_place) {
783
                    $places[] = new Place($ged_place, $this->tree);
784
                }
785
            }
786
        }
787
788
        return $places;
789
    }
790
791
    /**
792
     * The facts and events for this record.
793
     *
794
     * @param string[] $filter
795
     * @param bool     $sort
796
     * @param int|null $access_level
797
     * @param bool     $ignore_deleted
798
     *
799
     * @return Collection<Fact>
800
     */
801
    public function facts(
802
        array $filter = [],
803
        bool $sort = false,
804
        int $access_level = null,
805
        bool $ignore_deleted = false
806
    ): Collection {
807
        $access_level = $access_level ?? Auth::accessLevel($this->tree);
808
809
        $facts = new Collection();
810
        if ($this->canShow($access_level)) {
811
            foreach ($this->facts as $fact) {
812
                if (($filter === [] || in_array($fact->getTag(), $filter, true)) && $fact->canShow($access_level)) {
0 ignored issues
show
Deprecated Code introduced by
The function Fisharebest\Webtrees\Fact::getTag() has been deprecated: since 2.0.5. Will be removed in 2.1.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

812
                if (($filter === [] || in_array(/** @scrutinizer ignore-deprecated */ $fact->getTag(), $filter, true)) && $fact->canShow($access_level)) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
813
                    $facts->push($fact);
814
                }
815
            }
816
        }
817
818
        if ($sort) {
819
            $facts = Fact::sortFacts($facts);
820
        }
821
822
        if ($ignore_deleted) {
823
            $facts = $facts->filter(static function (Fact $fact): bool {
824
                return !$fact->isPendingDeletion();
825
            });
826
        }
827
828
        return new Collection($facts);
829
    }
830
831
    /**
832
     * Get the last-change timestamp for this record
833
     *
834
     * @return Carbon
835
     */
836
    public function lastChangeTimestamp(): Carbon
837
    {
838
        /** @var Fact|null $chan */
839
        $chan = $this->facts(['CHAN'])->first();
840
841
        if ($chan instanceof Fact) {
842
            // The record does have a CHAN event
843
            $d = $chan->date()->minimumDate();
844
845
            if (preg_match('/\n3 TIME (\d\d):(\d\d):(\d\d)/', $chan->gedcom(), $match)) {
846
                return Carbon::create($d->year(), $d->month(), $d->day(), (int) $match[1], (int) $match[2], (int) $match[3]);
847
            }
848
849
            if (preg_match('/\n3 TIME (\d\d):(\d\d)/', $chan->gedcom(), $match)) {
850
                return Carbon::create($d->year(), $d->month(), $d->day(), (int) $match[1], (int) $match[2]);
851
            }
852
853
            return Carbon::create($d->year(), $d->month(), $d->day());
854
        }
855
856
        // The record does not have a CHAN event
857
        return Carbon::createFromTimestamp(0);
858
    }
859
860
    /**
861
     * Get the last-change user for this record
862
     *
863
     * @return string
864
     */
865
    public function lastChangeUser(): string
866
    {
867
        $chan = $this->facts(['CHAN'])->first();
868
869
        if ($chan === null) {
870
            return I18N::translate('Unknown');
871
        }
872
873
        $chan_user = $chan->attribute('_WT_USER');
874
        if ($chan_user === '') {
875
            return I18N::translate('Unknown');
876
        }
877
878
        return $chan_user;
879
    }
880
881
    /**
882
     * Add a new fact to this record
883
     *
884
     * @param string $gedcom
885
     * @param bool   $update_chan
886
     *
887
     * @return void
888
     */
889
    public function createFact(string $gedcom, bool $update_chan): void
890
    {
891
        $this->updateFact('', $gedcom, $update_chan);
892
    }
893
894
    /**
895
     * Delete a fact from this record
896
     *
897
     * @param string $fact_id
898
     * @param bool   $update_chan
899
     *
900
     * @return void
901
     */
902
    public function deleteFact(string $fact_id, bool $update_chan): void
903
    {
904
        $this->updateFact($fact_id, '', $update_chan);
905
    }
906
907
    /**
908
     * Replace a fact with a new gedcom data.
909
     *
910
     * @param string $fact_id
911
     * @param string $gedcom
912
     * @param bool   $update_chan
913
     *
914
     * @return void
915
     * @throws Exception
916
     */
917
    public function updateFact(string $fact_id, string $gedcom, bool $update_chan): void
918
    {
919
        // Not all record types allow a CHAN event.
920
        $update_chan = $update_chan && in_array(static::RECORD_TYPE, Gedcom::RECORDS_WITH_CHAN, true);
921
922
        // MSDOS line endings will break things in horrible ways
923
        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
924
        $gedcom = trim($gedcom);
925
926
        if ($this->pending === '') {
927
            throw new Exception('Cannot edit a deleted record');
928
        }
929
        if ($gedcom !== '' && !preg_match('/^1 ' . Gedcom::REGEX_TAG . '/', $gedcom)) {
930
            throw new Exception('Invalid GEDCOM data passed to GedcomRecord::updateFact(' . $gedcom . ')');
931
        }
932
933
        if ($this->pending) {
934
            $old_gedcom = $this->pending;
935
        } else {
936
            $old_gedcom = $this->gedcom;
937
        }
938
939
        // First line of record may contain data - e.g. NOTE records.
940
        [$new_gedcom] = explode("\n", $old_gedcom, 2);
941
942
        // Replacing (or deleting) an existing fact
943
        foreach ($this->facts([], false, Auth::PRIV_HIDE, true) as $fact) {
944
            if ($fact->id() === $fact_id) {
945
                if ($gedcom !== '') {
946
                    $new_gedcom .= "\n" . $gedcom;
947
                }
948
                $fact_id = 'NOT A VALID FACT ID'; // Only replace/delete one copy of a duplicate fact
949
            } elseif ($fact->getTag() !== 'CHAN' || !$update_chan) {
950
                $new_gedcom .= "\n" . $fact->gedcom();
951
            }
952
        }
953
954
        // Adding a new fact
955
        if ($fact_id === '') {
956
            $new_gedcom .= "\n" . $gedcom;
957
        }
958
959
        if ($update_chan && !str_contains($new_gedcom, "\n1 CHAN")) {
960
            $today = strtoupper(date('d M Y'));
961
            $now   = date('H:i:s');
962
            $new_gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
963
        }
964
965
        if ($new_gedcom !== $old_gedcom) {
966
            // Save the changes
967
            DB::table('change')->insert([
968
                'gedcom_id'  => $this->tree->id(),
969
                'xref'       => $this->xref,
970
                'old_gedcom' => $old_gedcom,
971
                'new_gedcom' => $new_gedcom,
972
                'user_id'    => Auth::id(),
973
            ]);
974
975
            $this->pending = $new_gedcom;
976
            $this->invalidateInCache();
977
978
            if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
979
                app(PendingChangesService::class)->acceptRecord($this);
980
                $this->gedcom  = $new_gedcom;
981
                $this->pending = null;
982
            }
983
        }
984
        $this->parseFacts();
985
    }
986
987
    /**
988
     * Update this record
989
     *
990
     * @param string $gedcom
991
     * @param bool   $update_chan
992
     *
993
     * @return void
994
     */
995
    public function updateRecord(string $gedcom, bool $update_chan): void
996
    {
997
        // Not all record types allow a CHAN event.
998
        $update_chan = $update_chan && in_array(static::RECORD_TYPE, Gedcom::RECORDS_WITH_CHAN, true);
999
1000
        // MSDOS line endings will break things in horrible ways
1001
        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1002
        $gedcom = trim($gedcom);
1003
1004
        // Update the CHAN record
1005
        if ($update_chan) {
1006
            $gedcom = preg_replace('/\n1 CHAN(\n[2-9].*)*/', '', $gedcom);
1007
            $today = strtoupper(date('d M Y'));
1008
            $now   = date('H:i:s');
1009
            $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
1010
        }
1011
1012
        // Create a pending change
1013
        DB::table('change')->insert([
1014
            'gedcom_id'  => $this->tree->id(),
1015
            'xref'       => $this->xref,
1016
            'old_gedcom' => $this->gedcom(),
1017
            'new_gedcom' => $gedcom,
1018
            'user_id'    => Auth::id(),
1019
        ]);
1020
1021
        // Clear the cache
1022
        $this->pending = $gedcom;
1023
        $this->invalidateInCache();
1024
1025
        // Accept this pending change
1026
        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
1027
            app(PendingChangesService::class)->acceptRecord($this);
1028
            $this->gedcom  = $gedcom;
1029
            $this->pending = null;
1030
        }
1031
1032
        $this->parseFacts();
1033
1034
        Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1035
    }
1036
1037
    /**
1038
     * Delete this record
1039
     *
1040
     * @return void
1041
     */
1042
    public function deleteRecord(): void
1043
    {
1044
        // Create a pending change
1045
        if (!$this->isPendingDeletion()) {
1046
            DB::table('change')->insert([
1047
                'gedcom_id'  => $this->tree->id(),
1048
                'xref'       => $this->xref,
1049
                'old_gedcom' => $this->gedcom(),
1050
                'new_gedcom' => '',
1051
                'user_id'    => Auth::id(),
1052
            ]);
1053
        }
1054
1055
        $this->invalidateInCache();
1056
1057
        // Auto-accept this pending change
1058
        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
1059
            app(PendingChangesService::class)->acceptRecord($this);
1060
        }
1061
1062
        Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1063
    }
1064
1065
    /**
1066
     * Invalidate this record in the caches
1067
     * Only the array cache is invalidated
1068
     *
1069
     * @return bool
1070
     */
1071
    public function invalidateInCache(): bool
1072
    {
1073
        return Registry::cache()->array()->invalidateTags([
1074
            'gedrec-' . $this->xref . '@' . $this->tree()->id() . '',
1075
            'pending-t-' . $this->tree->id()
1076
        ]);
1077
    }
1078
1079
    /**
1080
     * Remove all links from this record to $xref
1081
     *
1082
     * @param string $xref
1083
     * @param bool   $update_chan
1084
     *
1085
     * @return void
1086
     */
1087
    public function removeLinks(string $xref, bool $update_chan): void
1088
    {
1089
        $value = '@' . $xref . '@';
1090
1091
        foreach ($this->facts() as $fact) {
1092
            if ($fact->value() === $value) {
1093
                $this->deleteFact($fact->id(), $update_chan);
1094
            } elseif (preg_match_all('/\n(\d) ' . Gedcom::REGEX_TAG . ' ' . $value . '/', $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1095
                $gedcom = $fact->gedcom();
1096
                foreach ($matches as $match) {
1097
                    $next_level  = $match[1] + 1;
1098
                    $next_levels = '[' . $next_level . '-9]';
1099
                    $gedcom      = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom);
1100
                }
1101
                $this->updateFact($fact->id(), $gedcom, $update_chan);
1102
            }
1103
        }
1104
    }
1105
1106
    /**
1107
     * Fetch XREFs of all records linked to a record - when deleting an object, we must
1108
     * also delete all links to it.
1109
     *
1110
     * @return GedcomRecord[]
1111
     */
1112
    public function linkingRecords(): array
1113
    {
1114
        $like = addcslashes($this->xref(), '\\%_');
1115
1116
        $union = DB::table('change')
1117
            ->where('gedcom_id', '=', $this->tree()->id())
1118
            ->where('new_gedcom', 'LIKE', '%@' . $like . '@%')
1119
            ->where('new_gedcom', 'NOT LIKE', '0 @' . $like . '@%')
1120
            ->whereIn('change_id', function (Builder $query): void {
1121
                $query->select(new Expression('MAX(change_id)'))
1122
                    ->from('change')
1123
                    ->where('gedcom_id', '=', $this->tree->id())
1124
                    ->where('status', '=', 'pending')
1125
                    ->groupBy(['xref']);
1126
            })
1127
            ->select(['xref']);
1128
1129
        $xrefs = DB::table('link')
1130
            ->where('l_file', '=', $this->tree()->id())
1131
            ->where('l_to', '=', $this->xref())
1132
            ->select(['l_from'])
1133
            ->union($union)
1134
            ->pluck('l_from');
1135
1136
        return $xrefs->map(function (string $xref): GedcomRecord {
1137
            $record = Registry::gedcomRecordFactory()->make($xref, $this->tree);
1138
            assert($record instanceof GedcomRecord);
1139
1140
            return $record;
1141
        })->all();
1142
    }
1143
1144
    /**
1145
     * Each object type may have its own special rules, and re-implement this function.
1146
     *
1147
     * @param int $access_level
1148
     *
1149
     * @return bool
1150
     */
1151
    protected function canShowByType(int $access_level): bool
1152
    {
1153
        $fact_privacy = $this->tree->getFactPrivacy();
1154
1155
        if (isset($fact_privacy[static::RECORD_TYPE])) {
1156
            // Restriction found
1157
            return $fact_privacy[static::RECORD_TYPE] >= $access_level;
1158
        }
1159
1160
        // No restriction found - must be public:
1161
        return true;
1162
    }
1163
1164
    /**
1165
     * Generate a private version of this record
1166
     *
1167
     * @param int $access_level
1168
     *
1169
     * @return string
1170
     */
1171
    protected function createPrivateGedcomRecord(int $access_level): string
1172
    {
1173
        return '0 @' . $this->xref . '@ ' . static::RECORD_TYPE;
1174
    }
1175
1176
    /**
1177
     * Convert a name record into sortable and full/display versions. This default
1178
     * should be OK for simple record types. INDI/FAM records will need to redefine it.
1179
     *
1180
     * @param string $type
1181
     * @param string $value
1182
     * @param string $gedcom
1183
     *
1184
     * @return void
1185
     */
1186
    protected function addName(string $type, string $value, string $gedcom): void
1187
    {
1188
        $this->getAllNames[] = [
1189
            'type'   => $type,
1190
            'sort'   => preg_replace_callback('/([0-9]+)/', static function (array $matches): string {
1191
                return str_pad($matches[0], 10, '0', STR_PAD_LEFT);
1192
            }, $value),
1193
            'full'   => '<span dir="auto">' . e($value) . '</span>',
1194
            // This is used for display
1195
            'fullNN' => $value,
1196
            // This goes into the database
1197
        ];
1198
    }
1199
1200
    /**
1201
     * Get all the names of a record, including ROMN, FONE and _HEB alternatives.
1202
     * Records without a name (e.g. FAM) will need to redefine this function.
1203
     * Parameters: the level 1 fact containing the name.
1204
     * Return value: an array of name structures, each containing
1205
     * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc.
1206
     * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown'
1207
     * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John'
1208
     *
1209
     * @param int              $level
1210
     * @param string           $fact_type
1211
     * @param Collection<Fact> $facts
1212
     *
1213
     * @return void
1214
     */
1215
    protected function extractNamesFromFacts(int $level, string $fact_type, Collection $facts): void
1216
    {
1217
        $sublevel    = $level + 1;
1218
        $subsublevel = $sublevel + 1;
1219
        foreach ($facts as $fact) {
1220
            if (preg_match_all("/^{$level} ({$fact_type}) (.+)((\n[{$sublevel}-9].+)*)/m", $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1221
                foreach ($matches as $match) {
1222
                    // Treat 1 NAME / 2 TYPE married the same as _MARNM
1223
                    if ($match[1] === 'NAME' && str_contains($match[3], "\n2 TYPE married")) {
1224
                        $this->addName('_MARNM', $match[2], $fact->gedcom());
1225
                    } else {
1226
                        $this->addName($match[1], $match[2], $fact->gedcom());
1227
                    }
1228
                    if ($match[3] && preg_match_all("/^{$sublevel} (ROMN|FONE|_\w+) (.+)((\n[{$subsublevel}-9].+)*)/m", $match[3], $submatches, PREG_SET_ORDER)) {
1229
                        foreach ($submatches as $submatch) {
1230
                            $this->addName($submatch[1], $submatch[2], $match[3]);
1231
                        }
1232
                    }
1233
                }
1234
            }
1235
        }
1236
    }
1237
1238
    /**
1239
     * Split the record into facts
1240
     *
1241
     * @return void
1242
     */
1243
    private function parseFacts(): void
1244
    {
1245
        // Split the record into facts
1246
        if ($this->gedcom) {
1247
            $gedcom_facts = preg_split('/\n(?=1)/', $this->gedcom);
1248
            array_shift($gedcom_facts);
1249
        } else {
1250
            $gedcom_facts = [];
1251
        }
1252
        if ($this->pending) {
1253
            $pending_facts = preg_split('/\n(?=1)/', $this->pending);
1254
            array_shift($pending_facts);
1255
        } else {
1256
            $pending_facts = [];
1257
        }
1258
1259
        $this->facts = [];
1260
1261
        foreach ($gedcom_facts as $gedcom_fact) {
1262
            $fact = new Fact($gedcom_fact, $this, md5($gedcom_fact));
1263
            if ($this->pending !== null && !in_array($gedcom_fact, $pending_facts, true)) {
1264
                $fact->setPendingDeletion();
1265
            }
1266
            $this->facts[] = $fact;
1267
        }
1268
        foreach ($pending_facts as $pending_fact) {
1269
            if (!in_array($pending_fact, $gedcom_facts, true)) {
1270
                $fact = new Fact($pending_fact, $this, md5($pending_fact));
1271
                $fact->setPendingAddition();
1272
                $this->facts[] = $fact;
1273
            }
1274
        }
1275
    }
1276
1277
    /**
1278
     * Work out whether this record can be shown to a user with a given access level
1279
     *
1280
     * @param int $access_level
1281
     *
1282
     * @return bool
1283
     */
1284
    private function canShowRecord(int $access_level): bool
1285
    {
1286
        // This setting would better be called "$ENABLE_PRIVACY"
1287
        if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) {
1288
            return true;
1289
        }
1290
1291
        // We should always be able to see our own record (unless an admin is applying download restrictions)
1292
        if ($this->xref() === $this->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF) && $access_level === Auth::accessLevel($this->tree)) {
1293
            return true;
1294
        }
1295
1296
        // Does this record have a RESN?
1297
        if (str_contains($this->gedcom, "\n1 RESN confidential")) {
1298
            return Auth::PRIV_NONE >= $access_level;
1299
        }
1300
        if (str_contains($this->gedcom, "\n1 RESN privacy")) {
1301
            return Auth::PRIV_USER >= $access_level;
1302
        }
1303
        if (str_contains($this->gedcom, "\n1 RESN none")) {
1304
            return true;
1305
        }
1306
1307
        // Does this record have a default RESN?
1308
        $individual_privacy = $this->tree->getIndividualPrivacy();
1309
        if (isset($individual_privacy[$this->xref()])) {
1310
            return $individual_privacy[$this->xref()] >= $access_level;
1311
        }
1312
1313
        // Privacy rules do not apply to admins
1314
        if (Auth::PRIV_NONE >= $access_level) {
1315
            return true;
1316
        }
1317
1318
        // Different types of record have different privacy rules
1319
        return $this->canShowByType($access_level);
1320
    }
1321
1322
    /**
1323
     * Lock the database row, to prevent concurrent edits.
1324
     */
1325
    public function lock(): void
1326
    {
1327
        DB::table('other')
1328
            ->where('o_file', '=', $this->tree->id())
1329
            ->where('o_id', '=', $this->xref())
1330
            ->lockForUpdate()
1331
            ->get();
1332
    }
1333
1334
    /**
1335
     * Add blank lines, to allow a user to add/edit new values.
1336
     *
1337
     * @return string
1338
     */
1339
    public function insertMissingSubtags(): string
1340
    {
1341
        $gedcom = $this->insertMissingLevels($this->tag(), $this->gedcom());
1342
1343
        return preg_replace('/^0.*\n/', '', $gedcom);
1344
    }
1345
1346
    /**
1347
     * @param string $tag
1348
     * @param string $gedcom
1349
     *
1350
     * @return string
1351
     */
1352
    protected function insertMissingLevels(string $tag, string $gedcom): string
1353
    {
1354
        $next_level = substr_count($tag, ':') + 1;
1355
        $factory    = Registry::elementFactory();
1356
        $subtags    = $factory->make($tag)->subtags();
1357
1358
        // The first part is level N (includes CONT records).  The remainder are level N+1.
1359
        $parts  = preg_split('/\n(?=' . $next_level . ')/', $gedcom);
1360
        $return = array_shift($parts);
1361
1362
        foreach ($subtags as $subtag => $occurrences) {
1363
            [$min, $max] = explode(':', $occurrences);
1364
            if ($max === 'M') {
1365
                $max = PHP_INT_MAX;
1366
            } else {
1367
                $max = (int) $max;
1368
            }
1369
1370
            $count = 0;
1371
1372
            // Add expected subtags in our preferred order.
1373
            foreach ($parts as $n => $part) {
1374
                if (str_starts_with($part, $next_level . ' ' . $subtag)) {
1375
                    $return .= "\n" . $this->insertMissingLevels($tag . ':' . $subtag, $part);
1376
                    $count++;
1377
                    unset($parts[$n]);
1378
                }
1379
            }
1380
1381
            // Allowed to have more of this subtag?
1382
            if ($count < $max) {
1383
                // Create a new one.
1384
                $gedcom  = $next_level . ' ' . $subtag;
1385
                $default = $factory->make($tag . ':' . $subtag)->default($this->tree);
1386
                if ($default !== '') {
1387
                    $gedcom .= ' ' . $default;
1388
                }
1389
                $return .= "\n" . $this->insertMissingLevels($tag . ':' . $subtag, $gedcom);
1390
            }
1391
        }
1392
1393
        // Now add any unexpected/existing data.
1394
        if ($parts !== []) {
1395
            $return .= "\n" . implode("\n", $parts);
1396
        }
1397
1398
        return $return;
1399
    }
1400
}
1401