Completed
Push — develop ( 2ca406...b33d79 )
by Greg
27:09 queued 11:25
created

GedcomRecord::slug()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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

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