Completed
Push — develop ( 0d6c46...c341e4 )
by Greg
15:25 queued 09:07
created

GedcomRecord::insertMissingLevels()   B

Complexity

Conditions 8
Paths 38

Size

Total Lines 47
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

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

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