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

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