Passed
Push — dbal ( 950c73...0a612c )
by Greg
22:42 queued 08:08
created

GedcomRecord::url()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2023 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\TimestampInterface;
25
use Fisharebest\Webtrees\Contracts\UserInterface;
26
use Fisharebest\Webtrees\Elements\RestrictionNotice;
27
use Fisharebest\Webtrees\Http\RequestHandlers\GedcomRecordPage;
28
use Fisharebest\Webtrees\Services\PendingChangesService;
29
use Illuminate\Support\Collection;
30
31
use function array_combine;
32
use function array_keys;
33
use function array_map;
34
use function array_search;
35
use function array_shift;
36
use function count;
37
use function date;
38
use function e;
39
use function explode;
40
use function implode;
41
use function in_array;
42
use function md5;
43
use function preg_match;
44
use function preg_match_all;
45
use function preg_replace;
46
use function preg_replace_callback;
47
use function preg_split;
48
use function range;
49
use function route;
50
use function str_contains;
51
use function str_ends_with;
52
use function str_pad;
53
use function str_replace;
54
use function str_starts_with;
55
use function strtoupper;
56
use function strtr;
57
use function trim;
58
use function view;
59
60
use const PHP_INT_MAX;
61
use const PREG_SET_ORDER;
62
use const STR_PAD_LEFT;
63
64
/**
65
 * A GEDCOM object.
66
 */
67
class GedcomRecord
68
{
69
    public const RECORD_TYPE = 'UNKNOWN';
70
71
    protected const ROUTE_NAME = GedcomRecordPage::class;
72
73
    protected string $xref;
74
75
    protected Tree $tree;
76
77
    // GEDCOM data (before any pending edits)
78
    protected string $gedcom;
79
80
    // GEDCOM data (after any pending edits)
81
    protected string|null $pending;
82
83
    /** @var array<Fact> Facts extracted from $gedcom/$pending */
84
    protected array $facts;
85
86
    /** @var array<array<string>> All the names of this individual */
87
    protected array $getAllNames = [];
88
89
    /** @var int|null Cached result */
90
    private int|null $getPrimaryName = null;
91
92
    /** @var int|null Cached result */
93
    private int|null $getSecondaryName = null;
94
95
    /**
96
     * Create a GedcomRecord object from raw GEDCOM data.
97
     *
98
     * @param string      $xref
99
     * @param string      $gedcom  an empty string for new/pending records
100
     * @param string|null $pending null for a record with no pending edits,
101
     *                             empty string for records with pending deletions
102
     * @param Tree        $tree
103
     */
104
    public function __construct(string $xref, string $gedcom, string|null $pending, Tree $tree)
105
    {
106
        $this->xref    = $xref;
107
        $this->gedcom  = $gedcom;
108
        $this->pending = $pending;
109
        $this->tree    = $tree;
110
        $this->facts   = $this->parseFacts();
111
    }
112
113
    /**
114
     * A closure which will filter out private records.
115
     *
116
     * @return Closure(GedcomRecord):bool
117
     */
118
    public static function accessFilter(): Closure
119
    {
120
        return static fn (GedcomRecord $record): bool => $record->canShow();
121
    }
122
123
    /**
124
     * A closure which will compare records by name.
125
     *
126
     * @return Closure(GedcomRecord,GedcomRecord):int
127
     */
128
    public static function nameComparator(): Closure
129
    {
130
        return static function (GedcomRecord $x, GedcomRecord $y): int {
131
            if ($x->canShowName()) {
132
                if ($y->canShowName()) {
133
                    return I18N::comparator()($x->sortName(), $y->sortName());
134
                }
135
136
                return -1; // only $y is private
137
            }
138
139
            if ($y->canShowName()) {
140
                return 1; // only $x is private
141
            }
142
143
            return 0; // both $x and $y private
144
        };
145
    }
146
147
    /**
148
     * A closure which will compare records by change time.
149
     *
150
     * @param int $direction +1 to sort ascending, -1 to sort descending
151
     *
152
     * @return Closure(GedcomRecord,GedcomRecord):int
153
     */
154
    public static function lastChangeComparator(int $direction = 1): Closure
155
    {
156
        return static fn (GedcomRecord $x, GedcomRecord $y): int => $direction * ($x->lastChangeTimestamp() <=> $y->lastChangeTimestamp());
157
    }
158
159
    /**
160
     * Get the GEDCOM tag for this record.
161
     *
162
     * @return string
163
     */
164
    public function tag(): string
165
    {
166
        preg_match('/^0 @[^@]*@ (\w+)/', $this->gedcom(), $match);
167
168
        return $match[1] ?? static::RECORD_TYPE;
169
    }
170
171
    /**
172
     * Get the XREF for this record
173
     *
174
     * @return string
175
     */
176
    public function xref(): string
177
    {
178
        return $this->xref;
179
    }
180
181
    /**
182
     * Get the tree to which this record belongs
183
     *
184
     * @return Tree
185
     */
186
    public function tree(): Tree
187
    {
188
        return $this->tree;
189
    }
190
191
    /**
192
     * Application code should access data via Fact objects.
193
     * This function exists to support old code.
194
     *
195
     * @return string
196
     */
197
    public function gedcom(): string
198
    {
199
        return $this->pending ?? $this->gedcom;
200
    }
201
202
    /**
203
     * Does this record have a pending change?
204
     *
205
     * @return bool
206
     */
207
    public function isPendingAddition(): bool
208
    {
209
        return $this->pending !== null;
210
    }
211
212
    /**
213
     * Does this record have a pending deletion?
214
     *
215
     * @return bool
216
     */
217
    public function isPendingDeletion(): bool
218
    {
219
        return $this->pending === '';
220
    }
221
222
    /**
223
     * Generate a URL to this record.
224
     *
225
     * @return string
226
     */
227
    public function url(): string
228
    {
229
        return route(static::ROUTE_NAME, [
230
            'xref' => $this->xref(),
231
            'tree' => $this->tree->name(),
232
            'slug' => Registry::slugFactory()->make($this),
233
        ]);
234
    }
235
236
    /**
237
     * Can the details of this record be shown?
238
     *
239
     * @param int|null $access_level
240
     *
241
     * @return bool
242
     */
243
    public function canShow(int|null $access_level = null): bool
244
    {
245
        $access_level ??= Auth::accessLevel($this->tree);
246
247
        // We use this value to bypass privacy checks. For example,
248
        // when downloading data or when calculating privacy itself.
249
        if ($access_level === Auth::PRIV_HIDE) {
250
            return true;
251
        }
252
253
        $cache_key = 'show-' . $this->xref . '-' . $this->tree->id() . '-' . $access_level;
254
255
        return Registry::cache()->array()->remember($cache_key, fn () => $this->canShowRecord($access_level));
256
    }
257
258
    /**
259
     * Can the name of this record be shown?
260
     *
261
     * @param int|null $access_level
262
     *
263
     * @return bool
264
     */
265
    public function canShowName(int|null $access_level = null): bool
266
    {
267
        return $this->canShow($access_level);
268
    }
269
270
    /**
271
     * Can we edit this record?
272
     *
273
     * @return bool
274
     */
275
    public function canEdit(): bool
276
    {
277
        if ($this->isPendingDeletion()) {
278
            return false;
279
        }
280
281
        if (Auth::isManager($this->tree)) {
282
            return true;
283
        }
284
285
        $fact   = $this->facts(['RESN'])->first();
286
        $locked = $fact instanceof Fact && str_ends_with($fact->value(), RestrictionNotice::VALUE_LOCKED);
287
288
        return Auth::isEditor($this->tree) && !$locked;
289
    }
290
291
    /**
292
     * Remove private data from the raw gedcom record.
293
     */
294
    public function privatizeGedcom(int $access_level): string
295
    {
296
        if ($access_level === Auth::PRIV_HIDE) {
297
            return $this->gedcom;
298
        }
299
300
        if (!$this->canShow($access_level)) {
301
            return '';
302
        }
303
304
        // The record is not private, but parts of it may be.
305
306
        // Include the entire first line (for NOTE records)
307
        [$gedcom] = explode("\n", $this->gedcom . $this->pending, 2);
308
309
        // Check each of the facts for access
310
        foreach ($this->facts([], false, $access_level) as $fact) {
311
            $gedcom .= "\n" . $fact->gedcom();
312
        }
313
314
        // Remove links to missing and private records
315
        $patterns = [
316
            '/\n1 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(?:\n[2-9].*)*/',
317
            '/\n2 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(?:\n[3-9].*)*/',
318
            '/\n3 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(?:\n[4-9].*)*/',
319
            '/\n4 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(?:\n[5-9].*)*/',
320
        ];
321
322
        foreach ($patterns as $pattern) {
323
            preg_match_all($pattern, $gedcom, $matches, PREG_SET_ORDER);
324
325
            foreach ($matches as $match) {
326
                $xref   = $match[1];
327
                $record = Registry::gedcomRecordFactory()->make($xref, $this->tree);
328
329
                if ($record === null || !$record->canShow($access_level)) {
330
                    $gedcom = str_replace($match[0], '', $gedcom);
331
                }
332
            }
333
        }
334
335
        return $gedcom;
336
    }
337
338
    /**
339
     * Default for "other" object types
340
     *
341
     * @return void
342
     */
343
    public function extractNames(): void
344
    {
345
        $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
346
    }
347
348
    /**
349
     * Derived classes should redefine this function, otherwise the object will have no name
350
     *
351
     * @return array<int,array<string,string>>
352
     */
353
    public function getAllNames(): array
354
    {
355
        if ($this->getAllNames === []) {
356
            if ($this->canShowName()) {
357
                // Ask the record to extract its names
358
                $this->extractNames();
359
                // No name found? Use a fallback.
360
                if ($this->getAllNames === []) {
361
                    $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
362
                }
363
            } else {
364
                $this->addName(static::RECORD_TYPE, I18N::translate('Private'), '');
365
            }
366
        }
367
368
        return $this->getAllNames;
369
    }
370
371
    /**
372
     * If this object has no name, what do we call it?
373
     *
374
     * @return string
375
     */
376
    public function getFallBackName(): string
377
    {
378
        return e($this->xref());
379
    }
380
381
    /**
382
     * Which of the (possibly several) names of this record is the primary one.
383
     *
384
     * @return int
385
     */
386
    public function getPrimaryName(): int
387
    {
388
        static $language_script;
389
390
        $language_script ??= I18N::locale()->script()->code();
391
392
        if ($this->getPrimaryName === null) {
393
            // Generally, the first name is the primary one....
394
            $this->getPrimaryName = 0;
395
            // ...except when the language/name use different character sets
396
            foreach ($this->getAllNames() as $n => $name) {
397
                if (I18N::textScript($name['sort']) === $language_script) {
398
                    $this->getPrimaryName = $n;
399
                    break;
400
                }
401
            }
402
        }
403
404
        return $this->getPrimaryName;
405
    }
406
407
    /**
408
     * Which of the (possibly several) names of this record is the secondary one.
409
     *
410
     * @return int
411
     */
412
    public function getSecondaryName(): int
413
    {
414
        if ($this->getSecondaryName === null) {
415
            // Generally, the primary and secondary names are the same
416
            $this->getSecondaryName = $this->getPrimaryName();
417
            // ....except when there are names with different character sets
418
            $all_names = $this->getAllNames();
419
            if (count($all_names) > 1) {
420
                $primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']);
421
                foreach ($all_names as $n => $name) {
422
                    if ($n !== $this->getPrimaryName() && $name['type'] !== '_MARNM' && I18N::textScript($name['sort']) !== $primary_script) {
423
                        $this->getSecondaryName = $n;
424
                        break;
425
                    }
426
                }
427
            }
428
        }
429
430
        return $this->getSecondaryName;
431
    }
432
433
    /**
434
     * Allow the choice of primary name to be overridden, e.g. in a search result
435
     *
436
     * @param int|null $n
437
     *
438
     * @return void
439
     */
440
    public function setPrimaryName(int|null $n = null): void
441
    {
442
        $this->getPrimaryName   = $n;
443
        $this->getSecondaryName = null;
444
    }
445
446
    /**
447
     * Allow native PHP functions such as array_unique() to work with objects
448
     *
449
     * @return string
450
     */
451
    public function __toString(): string
452
    {
453
        return $this->xref . '@' . $this->tree->id();
454
    }
455
456
    /**
457
     * /**
458
     * Get variants of the name
459
     *
460
     * @return string
461
     */
462
    public function fullName(): string
463
    {
464
        if ($this->canShowName()) {
465
            $tmp = $this->getAllNames();
466
467
            return $tmp[$this->getPrimaryName()]['full'];
468
        }
469
470
        return I18N::translate('Private');
471
    }
472
473
    /**
474
     * Get a sortable version of the name. Do not display this!
475
     *
476
     * @return string
477
     */
478
    public function sortName(): string
479
    {
480
        // The sortable name is never displayed, no need to call canShowName()
481
        $tmp = $this->getAllNames();
482
483
        return $tmp[$this->getPrimaryName()]['sort'];
484
    }
485
486
    /**
487
     * Get the full name in an alternative character set
488
     *
489
     * @return string|null
490
     */
491
    public function alternateName(): string|null
492
    {
493
        if ($this->canShowName() && $this->getPrimaryName() !== $this->getSecondaryName()) {
494
            $all_names = $this->getAllNames();
495
496
            return $all_names[$this->getSecondaryName()]['full'];
497
        }
498
499
        return null;
500
    }
501
502
    /**
503
     * Format this object for display in a list
504
     *
505
     * @return string
506
     */
507
    public function formatList(): string
508
    {
509
        $html = '<a href="' . e($this->url()) . '">';
510
        $html .= '<b>' . $this->fullName() . '</b>';
511
        $html .= '</a>';
512
        $html .= $this->formatListDetails();
513
514
        return $html;
515
    }
516
517
    /**
518
     * This function should be redefined in derived classes to show any major
519
     * identifying characteristics of this record.
520
     *
521
     * @return string
522
     */
523
    public function formatListDetails(): string
524
    {
525
        return '';
526
    }
527
528
    /**
529
     * Extract/format the first fact from a list of facts.
530
     *
531
     * @param array<string> $facts
532
     * @param int           $style
533
     *
534
     * @return string
535
     */
536
    public function formatFirstMajorFact(array $facts, int $style): string
537
    {
538
        $fact = $this->facts($facts, true)->first();
539
540
        if ($fact === null) {
541
            return '';
542
        }
543
544
        // Only display if it has a date or place (or both)
545
        $attributes = [];
546
547
        if ($fact->date()->isOK()) {
548
            $attributes[] = view('fact-date', ['cal_link' => 'false', 'fact' => $fact, 'record' => $fact->record(), 'time' => false]);
549
        }
550
551
        if ($fact->place()->gedcomName() !== '' && $style === 2) {
552
            $attributes[] = $fact->place()->shortName();
553
        }
554
555
        if ($attributes === []) {
556
            return '';
557
        }
558
559
        return '<div><em>' . I18N::translate('%1$s: %2$s', $fact->label(), implode(' — ', $attributes)) . '</em></div>';
560
    }
561
562
    /**
563
     * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR).
564
     * This is used to display multiple events on the individual/family lists.
565
     * Multiple events can exist because of uncertainty in dates, dates in different
566
     * calendars, place-names in both latin and hebrew character sets, etc.
567
     * It also allows us to combine dates/places from different events in the summaries.
568
     *
569
     * @param array<string> $events
570
     *
571
     * @return array<Date>
572
     */
573
    public function getAllEventDates(array $events): array
574
    {
575
        $dates = [];
576
        foreach ($this->facts($events, false, null, true) as $event) {
577
            if ($event->date()->isOK()) {
578
                $dates[] = $event->date();
579
            }
580
        }
581
582
        return $dates;
583
    }
584
585
    /**
586
     * Get all the places for a particular type of event
587
     *
588
     * @param array<string> $events
589
     *
590
     * @return array<Place>
591
     */
592
    public function getAllEventPlaces(array $events): array
593
    {
594
        $places = [];
595
        foreach ($this->facts($events) as $event) {
596
            if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->gedcom(), $ged_places)) {
597
                foreach ($ged_places[1] as $ged_place) {
598
                    $places[] = new Place($ged_place, $this->tree);
599
                }
600
            }
601
        }
602
603
        return $places;
604
    }
605
606
    /**
607
     * The facts and events for this record.
608
     *
609
     * @param array<string> $filter
610
     * @param bool          $sort
611
     * @param int|null      $access_level
612
     * @param bool          $ignore_deleted
613
     *
614
     * @return Collection<int,Fact>
615
     */
616
    public function facts(
617
        array $filter = [],
618
        bool $sort = false,
619
        int|null $access_level = null,
620
        bool $ignore_deleted = false
621
    ): Collection {
622
        $access_level ??= Auth::accessLevel($this->tree);
623
624
        // Convert BIRT into INDI:BIRT, etc.
625
        $filter = array_map(fn (string $tag): string => $this->tag() . ':' . $tag, $filter);
626
627
        $facts = new Collection();
628
        if ($this->canShow($access_level)) {
629
            foreach ($this->facts as $fact) {
630
                if (($filter === [] || in_array($fact->tag(), $filter, true)) && $fact->canShow($access_level)) {
631
                    $facts->push($fact);
632
                }
633
            }
634
        }
635
636
        if ($sort) {
637
            switch ($this->tag()) {
638
                case Family::RECORD_TYPE:
639
                case Individual::RECORD_TYPE:
640
                    $facts = Fact::sortFacts($facts);
641
                    break;
642
643
                default:
644
                    $subtags = Registry::elementFactory()->make($this->tag())->subtags();
645
                    $subtags = array_map(fn (string $tag): string => $this->tag() . ':' . $tag, array_keys($subtags));
646
647
                    if ($subtags !== []) {
648
                        // Renumber keys from 1.
649
                        $subtags = array_combine(range(1, count($subtags)), $subtags);
650
                    }
651
652
                    $facts = $facts
653
                        ->sort(static function (Fact $x, Fact $y) use ($subtags): int {
654
                            $sort_x = array_search($x->tag(), $subtags, true) ?: PHP_INT_MAX;
655
                            $sort_y = array_search($y->tag(), $subtags, true) ?: PHP_INT_MAX;
656
657
                            return $sort_x <=> $sort_y;
658
                        });
659
                    break;
660
            }
661
        }
662
663
        if ($ignore_deleted) {
664
            $facts = $facts->filter(static fn (Fact $fact): bool => !$fact->isPendingDeletion());
665
        }
666
667
        return $facts;
668
    }
669
670
    /**
671
     * @return array<string,string>
672
     */
673
    public function missingFacts(): array
674
    {
675
        $missing_facts = [];
676
677
        foreach (Registry::elementFactory()->make($this->tag())->subtags() as $subtag => $repeat) {
678
            [, $max] = explode(':', $repeat);
679
            $max = $max === 'M' ? PHP_INT_MAX : (int) $max;
680
681
            if ($this->facts([$subtag], false, null, true)->count() < $max) {
682
                $missing_facts[$subtag] = $subtag;
683
                $missing_facts[$subtag] = Registry::elementFactory()->make($this->tag() . ':' . $subtag)->label();
684
            }
685
        }
686
687
        uasort($missing_facts, I18N::comparator());
688
689
        if (!Auth::canUploadMedia($this->tree, Auth::user())) {
690
            unset($missing_facts['OBJE']);
691
        }
692
693
        // We have special code for this.
694
        unset($missing_facts['FILE']);
695
696
        return $missing_facts;
697
    }
698
699
    /**
700
     * Get the last-change timestamp for this record
701
     *
702
     * @return TimestampInterface
703
     */
704
    public function lastChangeTimestamp(): TimestampInterface
705
    {
706
        $chan = $this->facts(['CHAN'])->first();
707
708
        if ($chan instanceof Fact) {
709
            // The record has a CHAN event.
710
            $date = $chan->date()->minimumDate();
711
            $ymd = sprintf('%04d-%02d-%02d', $date->year(), $date->month(), $date->day());
712
713
            if ($ymd !== '') {
714
                // The CHAN event has a valid DATE.
715
                if (preg_match('/\n3 TIME (([01]\d|2[0-3]):([0-5]\d):([0-5]\d))/', $chan->gedcom(), $match) === 1) {
716
                    return Registry::timestampFactory()->fromString($ymd . $match[1], 'Y-m-d H:i:s');
717
                }
718
719
                if (preg_match('/\n3 TIME (([01]\d|2[0-3]):([0-5]\d))/', $chan->gedcom(), $match) === 1) {
720
                    return Registry::timestampFactory()->fromString($ymd . $match[1], 'Y-m-d H:i');
721
                }
722
723
                return Registry::timestampFactory()->fromString($ymd, 'Y-m-d');
724
            }
725
        }
726
727
        // The record does not have a CHAN event
728
        return Registry::timestampFactory()->make(0);
729
    }
730
731
    /**
732
     * Get the last-change user for this record
733
     *
734
     * @return string
735
     */
736
    public function lastChangeUser(): string
737
    {
738
        $chan = $this->facts(['CHAN'])->first();
739
740
        if ($chan === null) {
741
            return I18N::translate('Unknown');
742
        }
743
744
        $chan_user = $chan->attribute('_WT_USER');
745
        if ($chan_user === '') {
746
            return I18N::translate('Unknown');
747
        }
748
749
        return $chan_user;
750
    }
751
752
    /**
753
     * Add a new fact to this record
754
     *
755
     * @param string $gedcom
756
     * @param bool   $update_chan
757
     *
758
     * @return void
759
     */
760
    public function createFact(string $gedcom, bool $update_chan): void
761
    {
762
        $this->updateFact('', $gedcom, $update_chan);
763
    }
764
765
    /**
766
     * Delete a fact from this record
767
     *
768
     * @param string $fact_id
769
     * @param bool   $update_chan
770
     *
771
     * @return void
772
     */
773
    public function deleteFact(string $fact_id, bool $update_chan): void
774
    {
775
        $this->updateFact($fact_id, '', $update_chan);
776
    }
777
778
    /**
779
     * Replace a fact with a new gedcom data.
780
     *
781
     * @param string $fact_id
782
     * @param string $gedcom
783
     * @param bool   $update_chan
784
     *
785
     * @return void
786
     * @throws Exception
787
     */
788
    public function updateFact(string $fact_id, string $gedcom, bool $update_chan): void
789
    {
790
        // Not all record types allow a CHAN event.
791
        $update_chan = $update_chan && in_array(static::RECORD_TYPE, Gedcom::RECORDS_WITH_CHAN, true);
792
793
        // MSDOS line endings will break things in horrible ways
794
        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
795
        $gedcom = trim($gedcom);
796
797
        if ($this->pending === '') {
798
            throw new Exception('Cannot edit a deleted record');
799
        }
800
        if ($gedcom !== '' && !preg_match('/^1 ' . Gedcom::REGEX_TAG . '/', $gedcom)) {
801
            throw new Exception('Invalid GEDCOM data passed to GedcomRecord::updateFact(' . $gedcom . ')');
802
        }
803
804
        if ($this->pending !== null && $this->pending !== '') {
805
            $old_gedcom = $this->pending;
806
        } else {
807
            $old_gedcom = $this->gedcom;
808
        }
809
810
        // First line of record may contain data - e.g. NOTE records.
811
        [$new_gedcom] = explode("\n", $old_gedcom, 2);
812
813
        // Replacing (or deleting) an existing fact
814
        foreach ($this->facts([], false, Auth::PRIV_HIDE, true) as $fact) {
815
            if ($fact->id() === $fact_id) {
816
                if ($gedcom !== '') {
817
                    $new_gedcom .= "\n" . $gedcom;
818
                }
819
                $fact_id = 'NOT A VALID FACT ID'; // Only replace/delete one copy of a duplicate fact
820
            } elseif ($update_chan && str_ends_with($fact->tag(), ':CHAN')) {
821
                $new_gedcom .= "\n" . $this->updateChange($fact->gedcom());
822
            } else {
823
                $new_gedcom .= "\n" . $fact->gedcom();
824
            }
825
        }
826
827
        // Adding a new fact
828
        if ($fact_id === '') {
829
            $new_gedcom .= "\n" . $gedcom;
830
        }
831
832
        if ($update_chan && !str_contains($new_gedcom, "\n1 CHAN")) {
833
            $new_gedcom .= $this->updateChange("\n1 CHAN");
834
        }
835
836
        if ($new_gedcom !== $old_gedcom) {
837
            // Save the changes
838
            DB::table('change')->insert([
839
                'gedcom_id'  => $this->tree->id(),
840
                'xref'       => $this->xref,
841
                'old_gedcom' => $old_gedcom,
842
                'new_gedcom' => $new_gedcom,
843
                'status'     => 'pending',
844
                'user_id'    => Auth::id(),
845
            ]);
846
847
            $this->pending = $new_gedcom;
848
849
            if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
850
                $pending_changes_service = Registry::container()->get(PendingChangesService::class);
851
852
                $pending_changes_service->acceptRecord($this);
853
                $this->gedcom  = $new_gedcom;
854
                $this->pending = null;
855
            }
856
        }
857
858
        $this->facts = $this->parseFacts();
859
    }
860
861
    /**
862
     * Update this record
863
     *
864
     * @param string $gedcom
865
     * @param bool   $update_chan
866
     *
867
     * @return void
868
     */
869
    public function updateRecord(string $gedcom, bool $update_chan): void
870
    {
871
        // Not all record types allow a CHAN event.
872
        $update_chan = $update_chan && in_array(static::RECORD_TYPE, Gedcom::RECORDS_WITH_CHAN, true);
873
874
        // MSDOS line endings will break things in horrible ways
875
        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
876
        $gedcom = trim($gedcom);
877
878
        // Update the CHAN record
879
        if ($update_chan) {
880
            if (preg_match('/\n1 CHAN(\n[2-9].*)*/', $gedcom, $match)) {
881
                $gedcom = strtr($gedcom, [$match[0] => $this->updateChange($match[0])]);
882
            } else {
883
                $gedcom .= $this->updateChange("\n1 CHAN");
884
            }
885
        }
886
887
        // Create a pending change
888
        DB::table('change')->insert([
889
            'gedcom_id'  => $this->tree->id(),
890
            'xref'       => $this->xref,
891
            'old_gedcom' => $this->gedcom(),
892
            'new_gedcom' => $gedcom,
893
            'status'     => 'pending',
894
            'user_id'    => Auth::id(),
895
        ]);
896
897
        // Clear the cache
898
        $this->pending = $gedcom;
899
900
        // Accept this pending change
901
        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
902
            $pending_changes_service = Registry::container()->get(PendingChangesService::class);
903
904
            $pending_changes_service->acceptRecord($this);
905
            $this->gedcom  = $gedcom;
906
            $this->pending = null;
907
        }
908
909
        $this->facts = $this->parseFacts();
910
911
        Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
912
    }
913
914
    /**
915
     * Delete this record
916
     *
917
     * @return void
918
     */
919
    public function deleteRecord(): void
920
    {
921
        // Create a pending change
922
        if (!$this->isPendingDeletion()) {
923
            DB::table('change')->insert([
924
                'gedcom_id'  => $this->tree->id(),
925
                'xref'       => $this->xref,
926
                'old_gedcom' => $this->gedcom(),
927
                'new_gedcom' => '',
928
                'status'     => 'pending',
929
                'user_id'    => Auth::id(),
930
            ]);
931
        }
932
933
        // Auto-accept this pending change
934
        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
935
            $pending_changes_service = Registry::container()->get(PendingChangesService::class);
936
            $pending_changes_service->acceptRecord($this);
937
        }
938
939
        Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
940
    }
941
942
    /**
943
     * Remove all links from this record to $xref
944
     *
945
     * @param string $xref
946
     * @param bool   $update_chan
947
     *
948
     * @return void
949
     */
950
    public function removeLinks(string $xref, bool $update_chan): void
951
    {
952
        $value = '@' . $xref . '@';
953
954
        foreach ($this->facts() as $fact) {
955
            if ($fact->value() === $value) {
956
                $this->deleteFact($fact->id(), $update_chan);
957
            } elseif (preg_match_all('/\n(\d) ' . Gedcom::REGEX_TAG . ' ' . $value . '/', $fact->gedcom(), $matches, PREG_SET_ORDER)) {
958
                $gedcom = $fact->gedcom();
959
                foreach ($matches as $match) {
960
                    $next_level  = 1 + (int) $match[1];
961
                    $next_levels = '[' . $next_level . '-9]';
962
                    $gedcom      = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom);
963
                }
964
                $this->updateFact($fact->id(), $gedcom, $update_chan);
965
            }
966
        }
967
    }
968
969
    /**
970
     * Each object type may have its own special rules, and re-implement this function.
971
     *
972
     * @param int $access_level
973
     *
974
     * @return bool
975
     */
976
    protected function canShowByType(int $access_level): bool
977
    {
978
        $fact_privacy = $this->tree->getFactPrivacy();
979
980
        if (isset($fact_privacy[static::RECORD_TYPE])) {
981
            // Restriction found
982
            return $fact_privacy[static::RECORD_TYPE] >= $access_level;
983
        }
984
985
        // No restriction found - must be public:
986
        return true;
987
    }
988
989
    /**
990
     * Convert a name record into sortable and full/display versions. This default
991
     * should be OK for simple record types. INDI/FAM records will need to redefine it.
992
     *
993
     * @param string $type
994
     * @param string $value
995
     * @param string $gedcom
996
     *
997
     * @return void
998
     */
999
    protected function addName(string $type, string $value, string $gedcom): void
1000
    {
1001
        $this->getAllNames[] = [
1002
            'type'   => $type,
1003
            'sort'   => preg_replace_callback('/(\d+)/', static fn (array $matches): string => str_pad($matches[0], 10, '0', STR_PAD_LEFT), $value),
1004
            'full'   => '<bdi>' . e($value) . '</bdi>',
1005
            // This is used for display
1006
            'fullNN' => $value,
1007
            // This goes into the database
1008
        ];
1009
    }
1010
1011
    /**
1012
     * Get all the names of a record, including ROMN, FONE and _HEB alternatives.
1013
     * Records without a name (e.g. FAM) will need to redefine this function.
1014
     * Parameters: the level 1 fact containing the name.
1015
     * Return value: an array of name structures, each containing
1016
     * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc.
1017
     * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown'
1018
     * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John'
1019
     *
1020
     * @param int              $level
1021
     * @param string           $fact_type
1022
     * @param Collection<int,Fact> $facts
1023
     *
1024
     * @return void
1025
     */
1026
    protected function extractNamesFromFacts(int $level, string $fact_type, Collection $facts): void
1027
    {
1028
        $sublevel    = $level + 1;
1029
        $subsublevel = $sublevel + 1;
1030
        foreach ($facts as $fact) {
1031
            if (preg_match_all('/^' . $level . ' (' . $fact_type . ') (.+)((\n[' . $sublevel . '-9].+)*)/m', $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1032
                foreach ($matches as $match) {
1033
                    // Treat 1 NAME / 2 TYPE married the same as _MARNM
1034
                    if ($match[1] === 'NAME' && str_contains(strtoupper($match[3]), "\n2 TYPE MARRIED")) {
1035
                        $this->addName('_MARNM', $match[2], $fact->gedcom());
1036
                    } else {
1037
                        $this->addName($match[1], $match[2], $fact->gedcom());
1038
                    }
1039
                    if ($match[3] && preg_match_all('/^' . $sublevel . ' (ROMN|FONE|_\w+) (.+)((\n[' . $subsublevel . '-9].+)*)/m', $match[3], $submatches, PREG_SET_ORDER)) {
1040
                        foreach ($submatches as $submatch) {
1041
                            if ($submatch[1] !== '_RUFNAME') {
1042
                                $this->addName($submatch[1], $submatch[2], $match[3]);
1043
                            }
1044
                        }
1045
                    }
1046
                }
1047
            }
1048
        }
1049
    }
1050
1051
    /**
1052
     * Split the record into facts
1053
     *
1054
     * @return array<Fact>
1055
     */
1056
    private function parseFacts(): array
1057
    {
1058
        // Split the record into facts
1059
        if ($this->gedcom !== '') {
1060
            $gedcom_facts = preg_split('/\n(?=1)/', $this->gedcom);
1061
            array_shift($gedcom_facts);
1062
        } else {
1063
            $gedcom_facts = [];
1064
        }
1065
        if ($this->pending !== null && $this->pending !== '') {
1066
            $pending_facts = preg_split('/\n(?=1)/', $this->pending);
1067
            array_shift($pending_facts);
1068
        } else {
1069
            $pending_facts = [];
1070
        }
1071
1072
        $facts = [];
1073
1074
        foreach ($gedcom_facts as $gedcom_fact) {
1075
            $fact = new Fact($gedcom_fact, $this, md5($gedcom_fact));
1076
            if ($this->pending !== null && !in_array($gedcom_fact, $pending_facts, true)) {
1077
                $fact->setPendingDeletion();
1078
            }
1079
            $facts[] = $fact;
1080
        }
1081
        foreach ($pending_facts as $pending_fact) {
1082
            if (!in_array($pending_fact, $gedcom_facts, true)) {
1083
                $fact = new Fact($pending_fact, $this, md5($pending_fact));
1084
                $fact->setPendingAddition();
1085
                $facts[] = $fact;
1086
            }
1087
        }
1088
1089
        return $facts;
1090
    }
1091
1092
    /**
1093
     * Work out whether this record can be shown to a user with a given access level
1094
     *
1095
     * @param int $access_level
1096
     *
1097
     * @return bool
1098
     */
1099
    private function canShowRecord(int $access_level): bool
1100
    {
1101
        // This setting would better be called "$ENABLE_PRIVACY"
1102
        if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) {
1103
            return true;
1104
        }
1105
1106
        // We should always be able to see our own record (unless an admin is applying download restrictions)
1107
        if ($this->xref() === $this->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF) && $access_level === Auth::accessLevel($this->tree)) {
1108
            return true;
1109
        }
1110
1111
        // Does this record have a restriction notice?
1112
        // Cannot use $this->>fact(), as that function calls this one.
1113
        if (preg_match('/\n1 RESN (.+)/', $this->gedcom(), $match)) {
1114
            $element     = new RestrictionNotice('');
1115
            $restriction = $element->canonical($match[1]);
1116
1117
            if (str_starts_with($restriction, RestrictionNotice::VALUE_CONFIDENTIAL)) {
1118
                return Auth::PRIV_NONE >= $access_level;
1119
            }
1120
            if (str_starts_with($restriction, RestrictionNotice::VALUE_PRIVACY)) {
1121
                return Auth::PRIV_USER >= $access_level;
1122
            }
1123
            if (str_starts_with($restriction, RestrictionNotice::VALUE_NONE)) {
1124
                return true;
1125
            }
1126
        }
1127
1128
        // Does this record have a default RESN?
1129
        $individual_privacy = $this->tree->getIndividualPrivacy();
1130
        if (isset($individual_privacy[$this->xref()])) {
1131
            return $individual_privacy[$this->xref()] >= $access_level;
1132
        }
1133
1134
        // Privacy rules do not apply to admins
1135
        if (Auth::PRIV_NONE >= $access_level) {
1136
            return true;
1137
        }
1138
1139
        // Different types of record have different privacy rules
1140
        return $this->canShowByType($access_level);
1141
    }
1142
1143
    /**
1144
     * Lock the database row, to prevent concurrent edits.
1145
     */
1146
    public function lock(): void
1147
    {
1148
        DB::table('other')
1149
            ->where('o_file', '=', $this->tree->id())
1150
            ->where('o_id', '=', $this->xref())
1151
            ->lockForUpdate()
1152
            ->get();
1153
    }
1154
1155
    /**
1156
     * Change records may contain notes and other fields.  Just update the date/time/author.
1157
     *
1158
     * @param string $gedcom
1159
     *
1160
     * @return string
1161
     */
1162
    private function updateChange(string $gedcom): string
1163
    {
1164
        $gedcom = preg_replace('/\n2 (DATE|_WT_USER).*(\n[3-9].*)*/', '', $gedcom);
1165
        $today  = strtoupper(date('d M Y'));
1166
        $now    = date('H:i:s');
1167
        $author = Auth::user()->userName();
1168
1169
        return $gedcom . "\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . $author;
1170
    }
1171
}
1172