Completed
Pull Request — master (#3748)
by Jonathan
06:51
created

GedcomRecord::invalidateInCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 5
rs 10
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
        }, null, ['gedrec-' . $this->tree->id() . '-' . $this->xref]);
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 array<array<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(): string
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
            $this->invalidateInCache();
1009
1010
            if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
1011
                app(PendingChangesService::class)->acceptRecord($this);
1012
                $this->gedcom  = $new_gedcom;
1013
                $this->pending = null;
1014
            }
1015
        }
1016
        $this->parseFacts();
1017
    }
1018
1019
    /**
1020
     * Update this record
1021
     *
1022
     * @param string $gedcom
1023
     * @param bool   $update_chan
1024
     *
1025
     * @return void
1026
     */
1027
    public function updateRecord(string $gedcom, bool $update_chan): void
1028
    {
1029
        // Not all record types allow a CHAN event.
1030
        $update_chan = $update_chan && in_array(static::RECORD_TYPE, Gedcom::RECORDS_WITH_CHAN, true);
1031
1032
        // MSDOS line endings will break things in horrible ways
1033
        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1034
        $gedcom = trim($gedcom);
1035
1036
        // Update the CHAN record
1037
        if ($update_chan) {
1038
            $gedcom = preg_replace('/\n1 CHAN(\n[2-9].*)*/', '', $gedcom);
1039
            $today = strtoupper(date('d M Y'));
1040
            $now   = date('H:i:s');
1041
            $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
1042
        }
1043
1044
        // Create a pending change
1045
        DB::table('change')->insert([
1046
            'gedcom_id'  => $this->tree->id(),
1047
            'xref'       => $this->xref,
1048
            'old_gedcom' => $this->gedcom(),
1049
            'new_gedcom' => $gedcom,
1050
            'user_id'    => Auth::id(),
1051
        ]);
1052
1053
        // Clear the cache
1054
        $this->pending = $gedcom;
1055
        $this->invalidateInCache();
1056
1057
        // Accept this pending change
1058
        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
1059
            app(PendingChangesService::class)->acceptRecord($this);
1060
            $this->gedcom  = $gedcom;
1061
            $this->pending = null;
1062
        }
1063
1064
        $this->parseFacts();
1065
1066
        Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1067
    }
1068
1069
    /**
1070
     * Delete this record
1071
     *
1072
     * @return void
1073
     */
1074
    public function deleteRecord(): void
1075
    {
1076
        // Create a pending change
1077
        if (!$this->isPendingDeletion()) {
1078
            DB::table('change')->insert([
1079
                'gedcom_id'  => $this->tree->id(),
1080
                'xref'       => $this->xref,
1081
                'old_gedcom' => $this->gedcom(),
1082
                'new_gedcom' => '',
1083
                'user_id'    => Auth::id(),
1084
            ]);
1085
        }
1086
1087
        $this->invalidateInCache();
1088
1089
        // Auto-accept this pending change
1090
        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
1091
            app(PendingChangesService::class)->acceptRecord($this);
1092
        }
1093
1094
        Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1095
    }
1096
1097
    /**
1098
     * Invalidate this record in the caches
1099
     * Only the array cache is invalidated
1100
     *
1101
     * @return bool
1102
     */
1103
    public function invalidateInCache(): bool
1104
    {
1105
        return Registry::cache()->array()->invalidateTags([
1106
            'gedrec-' . $this->xref . '@' . $this->tree()->id() . '',
1107
            'pending-t-' . $this->tree->id()
1108
        ]);
1109
    }
1110
1111
    /**
1112
     * Remove all links from this record to $xref
1113
     *
1114
     * @param string $xref
1115
     * @param bool   $update_chan
1116
     *
1117
     * @return void
1118
     */
1119
    public function removeLinks(string $xref, bool $update_chan): void
1120
    {
1121
        $value = '@' . $xref . '@';
1122
1123
        foreach ($this->facts() as $fact) {
1124
            if ($fact->value() === $value) {
1125
                $this->deleteFact($fact->id(), $update_chan);
1126
            } elseif (preg_match_all('/\n(\d) ' . Gedcom::REGEX_TAG . ' ' . $value . '/', $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1127
                $gedcom = $fact->gedcom();
1128
                foreach ($matches as $match) {
1129
                    $next_level  = $match[1] + 1;
1130
                    $next_levels = '[' . $next_level . '-9]';
1131
                    $gedcom      = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom);
1132
                }
1133
                $this->updateFact($fact->id(), $gedcom, $update_chan);
1134
            }
1135
        }
1136
    }
1137
1138
    /**
1139
     * Fetch XREFs of all records linked to a record - when deleting an object, we must
1140
     * also delete all links to it.
1141
     *
1142
     * @return GedcomRecord[]
1143
     */
1144
    public function linkingRecords(): array
1145
    {
1146
        $like = addcslashes($this->xref(), '\\%_');
1147
1148
        $union = DB::table('change')
1149
            ->where('gedcom_id', '=', $this->tree()->id())
1150
            ->where('new_gedcom', 'LIKE', '%@' . $like . '@%')
1151
            ->where('new_gedcom', 'NOT LIKE', '0 @' . $like . '@%')
1152
            ->whereIn('change_id', function (Builder $query): void {
1153
                $query->select(new Expression('MAX(change_id)'))
1154
                    ->from('change')
1155
                    ->where('gedcom_id', '=', $this->tree->id())
1156
                    ->where('status', '=', 'pending')
1157
                    ->groupBy(['xref']);
1158
            })
1159
            ->select(['xref']);
1160
1161
        $xrefs = DB::table('link')
1162
            ->where('l_file', '=', $this->tree()->id())
1163
            ->where('l_to', '=', $this->xref())
1164
            ->select(['l_from'])
1165
            ->union($union)
1166
            ->pluck('l_from');
1167
1168
        return $xrefs->map(function (string $xref): GedcomRecord {
1169
            $record = Registry::gedcomRecordFactory()->make($xref, $this->tree);
1170
            assert($record instanceof GedcomRecord);
1171
1172
            return $record;
1173
        })->all();
1174
    }
1175
1176
    /**
1177
     * Each object type may have its own special rules, and re-implement this function.
1178
     *
1179
     * @param int $access_level
1180
     *
1181
     * @return bool
1182
     */
1183
    protected function canShowByType(int $access_level): bool
1184
    {
1185
        $fact_privacy = $this->tree->getFactPrivacy();
1186
1187
        if (isset($fact_privacy[static::RECORD_TYPE])) {
1188
            // Restriction found
1189
            return $fact_privacy[static::RECORD_TYPE] >= $access_level;
1190
        }
1191
1192
        // No restriction found - must be public:
1193
        return true;
1194
    }
1195
1196
    /**
1197
     * Generate a private version of this record
1198
     *
1199
     * @param int $access_level
1200
     *
1201
     * @return string
1202
     */
1203
    protected function createPrivateGedcomRecord(int $access_level): string
1204
    {
1205
        return '0 @' . $this->xref . '@ ' . static::RECORD_TYPE;
1206
    }
1207
1208
    /**
1209
     * Convert a name record into sortable and full/display versions. This default
1210
     * should be OK for simple record types. INDI/FAM records will need to redefine it.
1211
     *
1212
     * @param string $type
1213
     * @param string $value
1214
     * @param string $gedcom
1215
     *
1216
     * @return void
1217
     */
1218
    protected function addName(string $type, string $value, string $gedcom): void
1219
    {
1220
        $this->getAllNames[] = [
1221
            'type'   => $type,
1222
            'sort'   => preg_replace_callback('/([0-9]+)/', static function (array $matches): string {
1223
                return str_pad($matches[0], 10, '0', STR_PAD_LEFT);
1224
            }, $value),
1225
            'full'   => '<span dir="auto">' . e($value) . '</span>',
1226
            // This is used for display
1227
            'fullNN' => $value,
1228
            // This goes into the database
1229
        ];
1230
    }
1231
1232
    /**
1233
     * Get all the names of a record, including ROMN, FONE and _HEB alternatives.
1234
     * Records without a name (e.g. FAM) will need to redefine this function.
1235
     * Parameters: the level 1 fact containing the name.
1236
     * Return value: an array of name structures, each containing
1237
     * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc.
1238
     * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown'
1239
     * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John'
1240
     *
1241
     * @param int              $level
1242
     * @param string           $fact_type
1243
     * @param Collection<Fact> $facts
1244
     *
1245
     * @return void
1246
     */
1247
    protected function extractNamesFromFacts(int $level, string $fact_type, Collection $facts): void
1248
    {
1249
        $sublevel    = $level + 1;
1250
        $subsublevel = $sublevel + 1;
1251
        foreach ($facts as $fact) {
1252
            if (preg_match_all("/^{$level} ({$fact_type}) (.+)((\n[{$sublevel}-9].+)*)/m", $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1253
                foreach ($matches as $match) {
1254
                    // Treat 1 NAME / 2 TYPE married the same as _MARNM
1255
                    if ($match[1] === 'NAME' && str_contains($match[3], "\n2 TYPE married")) {
1256
                        $this->addName('_MARNM', $match[2], $fact->gedcom());
1257
                    } else {
1258
                        $this->addName($match[1], $match[2], $fact->gedcom());
1259
                    }
1260
                    if ($match[3] && preg_match_all("/^{$sublevel} (ROMN|FONE|_\w+) (.+)((\n[{$subsublevel}-9].+)*)/m", $match[3], $submatches, PREG_SET_ORDER)) {
1261
                        foreach ($submatches as $submatch) {
1262
                            $this->addName($submatch[1], $submatch[2], $match[3]);
1263
                        }
1264
                    }
1265
                }
1266
            }
1267
        }
1268
    }
1269
1270
    /**
1271
     * Split the record into facts
1272
     *
1273
     * @return void
1274
     */
1275
    private function parseFacts(): void
1276
    {
1277
        // Split the record into facts
1278
        if ($this->gedcom) {
1279
            $gedcom_facts = preg_split('/\n(?=1)/', $this->gedcom);
1280
            array_shift($gedcom_facts);
1281
        } else {
1282
            $gedcom_facts = [];
1283
        }
1284
        if ($this->pending) {
1285
            $pending_facts = preg_split('/\n(?=1)/', $this->pending);
1286
            array_shift($pending_facts);
1287
        } else {
1288
            $pending_facts = [];
1289
        }
1290
1291
        $this->facts = [];
1292
1293
        foreach ($gedcom_facts as $gedcom_fact) {
1294
            $fact = new Fact($gedcom_fact, $this, md5($gedcom_fact));
1295
            if ($this->pending !== null && !in_array($gedcom_fact, $pending_facts, true)) {
1296
                $fact->setPendingDeletion();
1297
            }
1298
            $this->facts[] = $fact;
1299
        }
1300
        foreach ($pending_facts as $pending_fact) {
1301
            if (!in_array($pending_fact, $gedcom_facts, true)) {
1302
                $fact = new Fact($pending_fact, $this, md5($pending_fact));
1303
                $fact->setPendingAddition();
1304
                $this->facts[] = $fact;
1305
            }
1306
        }
1307
    }
1308
1309
    /**
1310
     * Work out whether this record can be shown to a user with a given access level
1311
     *
1312
     * @param int $access_level
1313
     *
1314
     * @return bool
1315
     */
1316
    private function canShowRecord(int $access_level): bool
1317
    {
1318
        // This setting would better be called "$ENABLE_PRIVACY"
1319
        if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) {
1320
            return true;
1321
        }
1322
1323
        // We should always be able to see our own record (unless an admin is applying download restrictions)
1324
        if ($this->xref() === $this->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF) && $access_level === Auth::accessLevel($this->tree)) {
1325
            return true;
1326
        }
1327
1328
        // Does this record have a RESN?
1329
        if (str_contains($this->gedcom, "\n1 RESN confidential")) {
1330
            return Auth::PRIV_NONE >= $access_level;
1331
        }
1332
        if (str_contains($this->gedcom, "\n1 RESN privacy")) {
1333
            return Auth::PRIV_USER >= $access_level;
1334
        }
1335
        if (str_contains($this->gedcom, "\n1 RESN none")) {
1336
            return true;
1337
        }
1338
1339
        // Does this record have a default RESN?
1340
        $individual_privacy = $this->tree->getIndividualPrivacy();
1341
        if (isset($individual_privacy[$this->xref()])) {
1342
            return $individual_privacy[$this->xref()] >= $access_level;
1343
        }
1344
1345
        // Privacy rules do not apply to admins
1346
        if (Auth::PRIV_NONE >= $access_level) {
1347
            return true;
1348
        }
1349
1350
        // Different types of record have different privacy rules
1351
        return $this->canShowByType($access_level);
1352
    }
1353
1354
    /**
1355
     * Lock the database row, to prevent concurrent edits.
1356
     */
1357
    public function lock(): void
1358
    {
1359
        DB::table('other')
1360
            ->where('o_file', '=', $this->tree->id())
1361
            ->where('o_id', '=', $this->xref())
1362
            ->lockForUpdate()
1363
            ->get();
1364
    }
1365
1366
    /**
1367
     * Add blank lines, to allow a user to add/edit new values.
1368
     *
1369
     * @return string
1370
     */
1371
    public function insertMissingSubtags(): string
1372
    {
1373
        $gedcom = $this->insertMissingLevels($this->tag(), $this->gedcom());
1374
1375
        return preg_replace('/^0.*\n/', '', $gedcom);
1376
    }
1377
1378
    /**
1379
     * @param string $tag
1380
     * @param string $gedcom
1381
     *
1382
     * @return string
1383
     */
1384
    protected function insertMissingLevels(string $tag, string $gedcom): string
1385
    {
1386
        $next_level = substr_count($tag, ':') + 1;
1387
        $factory    = Registry::elementFactory();
1388
        $subtags    = $factory->make($tag)->subtags();
1389
1390
        // The first part is level N (includes CONT records).  The remainder are level N+1.
1391
        $parts  = preg_split('/\n(?=' . $next_level . ')/', $gedcom);
1392
        $return = array_shift($parts);
1393
1394
        foreach ($subtags as $subtag => $occurrences) {
1395
            [$min, $max] = explode(':', $occurrences);
1396
            if ($max === 'M') {
1397
                $max = PHP_INT_MAX;
1398
            } else {
1399
                $max = (int) $max;
1400
            }
1401
1402
            $count = 0;
1403
1404
            // Add expected subtags in our preferred order.
1405
            foreach ($parts as $n => $part) {
1406
                if (str_starts_with($part, $next_level . ' ' . $subtag)) {
1407
                    $return .= "\n" . $this->insertMissingLevels($tag . ':' . $subtag, $part);
1408
                    $count++;
1409
                    unset($parts[$n]);
1410
                }
1411
            }
1412
1413
            // Allowed to have more of this subtag?
1414
            if ($count < $max) {
1415
                // Create a new one.
1416
                $gedcom  = $next_level . ' ' . $subtag;
1417
                $default = $factory->make($tag . ':' . $subtag)->default($this->tree);
1418
                if ($default !== '') {
1419
                    $gedcom .= ' ' . $default;
1420
                }
1421
                $return .= "\n" . $this->insertMissingLevels($tag . ':' . $subtag, $gedcom);
1422
            }
1423
        }
1424
1425
        // Now add any unexpected/existing data.
1426
        if ($parts !== []) {
1427
            $return .= "\n" . implode("\n", $parts);
1428
        }
1429
1430
        return $return;
1431
    }
1432
}
1433