Issues (2502)

app/Fact.php (1 issue)

Labels
Severity
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2025 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 Fisharebest\Webtrees\Elements\RestrictionNotice;
24
use Fisharebest\Webtrees\Services\GedcomService;
25
use Illuminate\Support\Collection;
26
use InvalidArgumentException;
27
28
use function array_flip;
29
use function array_key_exists;
30
use function count;
31
use function e;
32
use function implode;
33
use function in_array;
34
use function preg_match;
35
use function preg_replace;
36
use function str_contains;
37
use function str_ends_with;
38
use function str_starts_with;
39
use function usort;
40
41
/**
42
 * A GEDCOM fact or event object.
43
 */
44
class Fact
45
{
46
    private const array FACT_ORDER = [
0 ignored issues
show
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 46 at column 24
Loading history...
47
        'BIRT',
48
        '_HNM',
49
        'ALIA',
50
        '_AKA',
51
        '_AKAN',
52
        'ADOP',
53
        '_ADPF',
54
        '_ADPF',
55
        '_BRTM',
56
        'CHR',
57
        'BAPM',
58
        'FCOM',
59
        'CONF',
60
        'BARM',
61
        'BASM',
62
        'EDUC',
63
        'GRAD',
64
        '_DEG',
65
        'EMIG',
66
        'IMMI',
67
        'NATU',
68
        '_MILI',
69
        '_MILT',
70
        'ENGA',
71
        'MARB',
72
        'MARC',
73
        'MARL',
74
        '_MARI',
75
        '_MBON',
76
        'MARR',
77
        '_COML',
78
        '_STAT',
79
        '_SEPR',
80
        'DIVF',
81
        'MARS',
82
        'DIV',
83
        'ANUL',
84
        'CENS',
85
        'OCCU',
86
        'RESI',
87
        'PROP',
88
        'CHRA',
89
        'RETI',
90
        'FACT',
91
        'EVEN',
92
        '_NMR',
93
        '_NMAR',
94
        'NMR',
95
        'NCHI',
96
        'WILL',
97
        '_HOL',
98
        '_????_',
99
        'DEAT',
100
        '_FNRL',
101
        'CREM',
102
        'BURI',
103
        '_INTE',
104
        '_YART',
105
        '_NLIV',
106
        'PROB',
107
        'TITL',
108
        'COMM',
109
        'NATI',
110
        'CITN',
111
        'CAST',
112
        'RELI',
113
        'SSN',
114
        'IDNO',
115
        'TEMP',
116
        'SLGC',
117
        'BAPL',
118
        'CONL',
119
        'ENDL',
120
        'SLGS',
121
        'NO',
122
        'ADDR',
123
        'PHON',
124
        'EMAIL',
125
        '_EMAIL',
126
        'EMAL',
127
        'FAX',
128
        'WWW',
129
        'URL',
130
        '_URL',
131
        '_FSFTID',
132
        'AFN',
133
        'REFN',
134
        '_PRMN',
135
        'REF',
136
        'RIN',
137
        '_UID',
138
        'OBJE',
139
        'NOTE',
140
        'SOUR',
141
        'CREA',
142
        'CHAN',
143
        '_TODO',
144
    ];
145
146
    // Unique identifier for this fact (currently implemented as a hash of the raw data).
147
    private string $id;
148
149
    // The GEDCOM record from which this fact is taken
150
    private GedcomRecord $record;
151
152
    // The raw GEDCOM data for this fact
153
    private string $gedcom;
154
155
    // The GEDCOM tag for this record
156
    private string $tag;
157
158
    private bool $pending_deletion = false;
159
160
    private bool $pending_addition = false;
161
162
    private Date $date;
163
164
    private Place $place;
165
166
    // Used to sort facts
167
    public int $sortOrder;
168
169
    // Used by anniversary calculations
170
    public int $jd;
171
    public int $anniv;
172
173
    /**
174
     * Create an event object from a gedcom fragment.
175
     * We need the parent object (to check privacy) and a (pseudo) fact ID to
176
     * identify the fact within the record.
177
     *
178
     * @param string       $gedcom
179
     * @param GedcomRecord $parent
180
     * @param string       $id
181
     *
182
     * @throws InvalidArgumentException
183
     */
184
    public function __construct(string $gedcom, GedcomRecord $parent, string $id)
185
    {
186
        if (preg_match('/^1 (' . Gedcom::REGEX_TAG . ')/', $gedcom, $match)) {
187
            $this->gedcom = $gedcom;
188
            $this->record = $parent;
189
            $this->id     = $id;
190
            $this->tag    = $match[1];
191
        } else {
192
            throw new InvalidArgumentException('Invalid GEDCOM data passed to Fact::_construct(' . $gedcom . ',' . $parent->xref() . ')');
193
        }
194
    }
195
196
    /**
197
     * Get the value of level 1 data in the fact
198
     * Allow for multi-line values
199
     *
200
     * @return string
201
     */
202
    public function value(): string
203
    {
204
        if (preg_match('/^1 ' . $this->tag . ' ?(.*(?:\n2 CONT ?.*)*)/', $this->gedcom, $match)) {
205
            $value = preg_replace("/\n2 CONT ?/", "\n", $match[1]);
206
207
            return Registry::elementFactory()->make($this->tag())->canonical($value);
208
        }
209
210
        return '';
211
    }
212
213
    /**
214
     * Get the record to which this fact links
215
     *
216
     * @return Family|GedcomRecord|Individual|Location|Media|Note|Repository|Source|Submission|Submitter|null
217
     */
218
    public function target()
219
    {
220
        if (!preg_match('/^@(' . Gedcom::REGEX_XREF . ')@$/', $this->value(), $match)) {
221
            return null;
222
        }
223
224
        $xref = $match[1];
225
226
        switch ($this->tag) {
227
            case 'FAMC':
228
            case 'FAMS':
229
                return Registry::familyFactory()->make($xref, $this->record->tree());
230
            case 'HUSB':
231
            case 'WIFE':
232
            case 'ALIA':
233
            case 'CHIL':
234
            case '_ASSO':
235
                return Registry::individualFactory()->make($xref, $this->record->tree());
236
            case 'ASSO':
237
                return
238
                    Registry::individualFactory()->make($xref, $this->record->tree()) ??
239
                    Registry::submitterFactory()->make($xref, $this->record->tree());
240
            case 'SOUR':
241
                return Registry::sourceFactory()->make($xref, $this->record->tree());
242
            case 'OBJE':
243
                return Registry::mediaFactory()->make($xref, $this->record->tree());
244
            case 'REPO':
245
                return Registry::repositoryFactory()->make($xref, $this->record->tree());
246
            case 'NOTE':
247
                return Registry::noteFactory()->make($xref, $this->record->tree());
248
            case 'ANCI':
249
            case 'DESI':
250
            case 'SUBM':
251
                return Registry::submitterFactory()->make($xref, $this->record->tree());
252
            case 'SUBN':
253
                return Registry::submissionFactory()->make($xref, $this->record->tree());
254
            case '_LOC':
255
                return Registry::locationFactory()->make($xref, $this->record->tree());
256
            default:
257
                return Registry::gedcomRecordFactory()->make($xref, $this->record->tree());
258
        }
259
    }
260
261
    /**
262
     * Get the value of level 2 data in the fact
263
     *
264
     * @param string $tag
265
     *
266
     * @return string
267
     */
268
    public function attribute(string $tag): string
269
    {
270
        if (preg_match('/\n2 ' . $tag . '\b ?(.*(?:(?:\n3 CONT ?.*)*)*)/', $this->gedcom, $match)) {
271
            $value = preg_replace("/\n3 CONT ?/", "\n", $match[1]);
272
273
            return Registry::elementFactory()->make($this->tag() . ':' . $tag)->canonical($value);
274
        }
275
276
        return '';
277
    }
278
279
    /**
280
     * Get the PLAC:MAP:LATI for the fact.
281
     */
282
    public function latitude(): float|null
283
    {
284
        if (preg_match('/\n4 LATI (.+)/', $this->gedcom, $match)) {
285
            $gedcom_service = new GedcomService();
286
287
            return $gedcom_service->readLatitude($match[1]);
288
        }
289
290
        return null;
291
    }
292
293
    /**
294
     * Get the PLAC:MAP:LONG for the fact.
295
     */
296
    public function longitude(): float|null
297
    {
298
        if (preg_match('/\n4 LONG (.+)/', $this->gedcom, $match)) {
299
            $gedcom_service = new GedcomService();
300
301
            return $gedcom_service->readLongitude($match[1]);
302
        }
303
304
        return null;
305
    }
306
307
    /**
308
     * Do the privacy rules allow us to display this fact to the current user
309
     *
310
     * @param int|null $access_level
311
     *
312
     * @return bool
313
     */
314
    public function canShow(int|null $access_level = null): bool
315
    {
316
        $access_level ??= Auth::accessLevel($this->record->tree());
317
318
        // Does this record have an explicit restriction notice?
319
        $element     = new RestrictionNotice('');
320
        $restriction = $element->canonical($this->attribute('RESN'));
321
322
        if (str_starts_with($restriction, RestrictionNotice::VALUE_CONFIDENTIAL)) {
323
            return Auth::PRIV_NONE >= $access_level;
324
        }
325
326
        if (str_starts_with($restriction, RestrictionNotice::VALUE_PRIVACY)) {
327
            return Auth::PRIV_USER >= $access_level;
328
        }
329
        if (str_starts_with($restriction, RestrictionNotice::VALUE_NONE)) {
330
            return true;
331
        }
332
333
        // A link to a record of the same type: NOTE=>NOTE, OBJE=>OBJE, SOUR=>SOUR, etc.
334
        // Use the privacy of the target record.
335
        $target = $this->target();
336
337
        if ($target instanceof GedcomRecord && $target->tag() === $this->tag) {
338
            return $target->canShow($access_level);
339
        }
340
341
        // Does this record have a default RESN?
342
        $xref                    = $this->record->xref();
343
        $fact_privacy            = $this->record->tree()->getFactPrivacy();
344
        $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy();
345
        if (isset($individual_fact_privacy[$xref][$this->tag])) {
346
            return $individual_fact_privacy[$xref][$this->tag] >= $access_level;
347
        }
348
        if (isset($fact_privacy[$this->tag])) {
349
            return $fact_privacy[$this->tag] >= $access_level;
350
        }
351
352
        // No restrictions - it must be public
353
        return true;
354
    }
355
356
    /**
357
     * Check whether this fact is protected against edit
358
     *
359
     * @return bool
360
     */
361
    public function canEdit(): bool
362
    {
363
        if ($this->isPendingDeletion()) {
364
            return false;
365
        }
366
367
        if (Auth::isManager($this->record->tree())) {
368
            return true;
369
        }
370
371
        // Members cannot edit RESN, CHAN and locked records
372
        return Auth::isEditor($this->record->tree()) && !str_ends_with($this->attribute('RESN'), RestrictionNotice::VALUE_LOCKED) && $this->tag !== 'RESN' && $this->tag !== 'CHAN';
373
    }
374
375
    /**
376
     * The place where the event occurred.
377
     *
378
     * @return Place
379
     */
380
    public function place(): Place
381
    {
382
        $this->place ??= new Place($this->attribute('PLAC'), $this->record->tree());
383
384
        return $this->place;
385
    }
386
387
    /**
388
     * Get the date for this fact.
389
     * We can call this function many times, especially when sorting,
390
     * so keep a copy of the date.
391
     *
392
     * @return Date
393
     */
394
    public function date(): Date
395
    {
396
        $this->date ??= new Date($this->attribute('DATE'));
397
398
        return $this->date;
399
    }
400
401
    /**
402
     * The raw GEDCOM data for this fact
403
     *
404
     * @return string
405
     */
406
    public function gedcom(): string
407
    {
408
        return $this->gedcom;
409
    }
410
411
    /**
412
     * Get a (pseudo) primary key for this fact.
413
     *
414
     * @return string
415
     */
416
    public function id(): string
417
    {
418
        return $this->id;
419
    }
420
421
    /**
422
     * What is the tag (type) of this fact, such as BIRT, MARR or DEAT.
423
     *
424
     * @return string
425
     */
426
    public function tag(): string
427
    {
428
        return $this->record->tag() . ':' . $this->tag;
429
    }
430
431
    /**
432
     * The GEDCOM record where this Fact came from
433
     *
434
     * @return GedcomRecord
435
     */
436
    public function record(): GedcomRecord
437
    {
438
        return $this->record;
439
    }
440
441
    /**
442
     * Get the name of this fact type, for use as a label.
443
     *
444
     * @return string
445
     */
446
    public function label(): string
447
    {
448
        if (str_ends_with($this->tag(), ':NOTE') && preg_match('/^@' . Gedcom::REGEX_XREF . '@$/', $this->value())) {
449
            return I18N::translate('Shared note');
450
        }
451
452
        // Marriages
453
        if ($this->tag() === 'FAM:MARR') {
454
            $element = Registry::elementFactory()->make('FAM:MARR:TYPE');
455
            $type = $this->attribute('TYPE');
456
457
            if ($type !== '') {
458
                return $element->value($type, $this->record->tree());
459
            }
460
        }
461
462
        // Custom FACT/EVEN - with a TYPE
463
        if ($this->tag === 'FACT' || $this->tag === 'EVEN') {
464
            $type = $this->attribute('TYPE');
465
466
            if ($type !== '') {
467
                if (!str_contains($type, '%')) {
468
                    // Allow user-translations of custom types.
469
                    $translated = I18N::translate($type);
470
471
                    if ($translated !== $type) {
472
                        return $translated;
473
                    }
474
                }
475
476
                return e($type);
477
            }
478
        }
479
480
        return Registry::elementFactory()->make($this->tag())->label();
481
    }
482
483
    /**
484
     * This is a newly deleted fact, pending approval.
485
     *
486
     * @return void
487
     */
488
    public function setPendingDeletion(): void
489
    {
490
        $this->pending_deletion = true;
491
        $this->pending_addition = false;
492
    }
493
494
    /**
495
     * Is this a newly deleted fact, pending approval.
496
     *
497
     * @return bool
498
     */
499
    public function isPendingDeletion(): bool
500
    {
501
        return $this->pending_deletion;
502
    }
503
504
    /**
505
     * This is a newly added fact, pending approval.
506
     *
507
     * @return void
508
     */
509
    public function setPendingAddition(): void
510
    {
511
        $this->pending_addition = true;
512
        $this->pending_deletion = false;
513
    }
514
515
    /**
516
     * Is this a newly added fact, pending approval.
517
     *
518
     * @return bool
519
     */
520
    public function isPendingAddition(): bool
521
    {
522
        return $this->pending_addition;
523
    }
524
525
    /**
526
     * A one-line summary of the fact - for charts, etc.
527
     *
528
     * @return string
529
     */
530
    public function summary(): string
531
    {
532
        $attributes = [];
533
        $target     = $this->target();
534
        if ($target instanceof GedcomRecord) {
535
            $attributes[] = $target->fullName();
536
        } else {
537
            // Fact value
538
            $value = $this->value();
539
            if ($value !== '' && $value !== 'Y') {
540
                $attributes[] = '<bdi>' . e($value) . '</bdi>';
541
            }
542
            // Fact date
543
            $date = $this->date();
544
            if ($date->isOK()) {
545
                if (
546
                    $this->record instanceof Individual && in_array($this->tag, Gedcom::BIRTH_EVENTS, true) &&
547
                    $this->record->tree()->getPreference('SHOW_PARENTS_AGE') === '1'
548
                ) {
549
                    $attributes[] = $date->display() . view('fact-parents-age', ['individual' => $this->record, 'birth_date' => $date]);
550
                } else {
551
                    $attributes[] = $date->display();
552
                }
553
            }
554
            // Fact place
555
            if ($this->place()->gedcomName() !== '') {
556
                $attributes[] = $this->place()->shortName();
557
            }
558
        }
559
560
        $class = 'fact_' . $this->tag;
561
        if ($this->isPendingAddition()) {
562
            $class .= ' wt-new';
563
        } elseif ($this->isPendingDeletion()) {
564
            $class .= ' wt-old';
565
        }
566
567
        $label = '<span class="label">' . $this->label() . '</span>';
568
        $value = '<span class="field" dir="auto">' . implode(' — ', $attributes) . '</span>';
569
570
        /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
571
        return '<div class="' . $class . '">' . I18N::translate('%1$s: %2$s', $label, $value) . '</div>';
572
    }
573
574
    /**
575
     * A one-line summary of the fact - for the clipboard, etc.
576
     *
577
     * @return string
578
     */
579
    public function name(): string
580
    {
581
        $items  = [$this->label()];
582
        $target = $this->target();
583
584
        if ($target instanceof GedcomRecord) {
585
            $items[] = '<bdi>' . $target->fullName() . '</bdi>';
586
        } else {
587
            // Fact value
588
            $value = $this->value();
589
            if ($value !== '' && $value !== 'Y') {
590
                $items[] = '<bdi>' . e($value) . '</bdi>';
591
            }
592
593
            // Fact date
594
            if ($this->date()->isOK()) {
595
                $items[] = $this->date()->minimumDate()->format('%Y');
596
            }
597
598
            // Fact place
599
            if ($this->place()->gedcomName() !== '') {
600
                $items[] = $this->place()->shortName();
601
            }
602
        }
603
604
        return implode(' — ', $items);
605
    }
606
607
    /**
608
     * Helper functions to sort facts
609
     *
610
     * @return Closure(Fact,Fact):int
611
     */
612
    private static function dateComparator(): Closure
613
    {
614
        return static function (Fact $a, Fact $b): int {
615
            if ($a->date()->isOK() && $b->date()->isOK()) {
616
                // If both events have dates, compare by date
617
                $ret = Date::compare($a->date(), $b->date());
618
619
                if ($ret === 0) {
620
                    // If dates overlap, compare by fact type
621
                    $ret = self::typeComparator()($a, $b);
622
623
                    // If the fact type is also the same, retain the initial order
624
                    if ($ret === 0) {
625
                        $ret = $a->sortOrder <=> $b->sortOrder;
626
                    }
627
                }
628
629
                return $ret;
630
            }
631
632
            // One or both events have no date - retain the initial order
633
            return $a->sortOrder <=> $b->sortOrder;
634
        };
635
    }
636
637
    /**
638
     * Helper functions to sort facts.
639
     *
640
     * @return Closure(Fact,Fact):int
641
     */
642
    public static function typeComparator(): Closure
643
    {
644
        static $factsort = [];
645
646
        if ($factsort === []) {
647
            $factsort = array_flip(self::FACT_ORDER);
648
        }
649
650
        return static function (Fact $a, Fact $b) use ($factsort): int {
651
            // Facts from same families stay grouped together
652
            // Keep MARR and DIV from the same families from mixing with events from other FAMs
653
            // Use the original order in which the facts were added
654
            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
655
                return $a->sortOrder <=> $b->sortOrder;
656
            }
657
658
            // NO events sort as the non-event itself.
659
            $atag = $a->tag === 'NO' ? $a->value() : $a->tag;
660
            $btag = $b->tag === 'NO' ? $b->value() : $b->tag;
661
662
            // Events not in the above list get mapped onto one that is.
663
            if (!array_key_exists($atag, $factsort)) {
664
                $atag = '_????_';
665
            }
666
667
            if (!array_key_exists($btag, $factsort)) {
668
                $btag = '_????_';
669
            }
670
671
            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
672
            // - Treat dated after BURI facts as BURI instead
673
            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
674
                $atag = 'BURI';
675
            }
676
677
            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
678
                $btag = 'BURI';
679
            }
680
681
            // If facts are the same then put dated facts before non-dated facts
682
            if ($atag === $btag) {
683
                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
684
                    return -1;
685
                }
686
687
                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
688
                    return 1;
689
                }
690
691
                // If no sorting preference, then keep original ordering
692
                return $a->sortOrder <=> $b->sortOrder;
693
            }
694
695
            return $factsort[$atag] <=> $factsort[$btag];
696
        };
697
    }
698
699
    /**
700
     * A multi-key sort
701
     * 1. First divide the facts into two arrays one set with dates and one set without dates
702
     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
703
     * using the compare type function
704
     * 3. Then merge the arrays back into the original array using the compare type function
705
     *
706
     * @param Collection<int,Fact> $unsorted
707
     *
708
     * @return Collection<int,Fact>
709
     */
710
    public static function sortFacts(Collection $unsorted): Collection
711
    {
712
        $dated    = [];
713
        $nondated = [];
714
        $sorted   = [];
715
716
        // Split the array into dated and non-dated arrays
717
        $order = 0;
718
719
        foreach ($unsorted as $fact) {
720
            $fact->sortOrder = $order;
721
            $order++;
722
723
            if ($fact->date()->isOK()) {
724
                $dated[] = $fact;
725
            } else {
726
                $nondated[] = $fact;
727
            }
728
        }
729
730
        usort($dated, self::dateComparator());
731
        usort($nondated, self::typeComparator());
732
733
        // Merge the arrays
734
        $dc = count($dated);
735
        $nc = count($nondated);
736
        $i  = 0;
737
        $j  = 0;
738
739
        // while there is anything in the dated array continue merging
740
        while ($i < $dc) {
741
            // compare each fact by type to merge them in order
742
            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
743
                $sorted[] = $nondated[$j];
744
                $j++;
745
            } else {
746
                $sorted[] = $dated[$i];
747
                $i++;
748
            }
749
        }
750
751
        // get anything that might be left in the nondated array
752
        while ($j < $nc) {
753
            $sorted[] = $nondated[$j];
754
            $j++;
755
        }
756
757
        return new Collection($sorted);
758
    }
759
760
    /**
761
     * Sort fact/event tags using the same order that we use for facts.
762
     *
763
     * @param Collection<int,string> $unsorted
764
     *
765
     * @return Collection<int,string>
766
     */
767
    public static function sortFactTags(Collection $unsorted): Collection
768
    {
769
        $tag_order = array_flip(self::FACT_ORDER);
770
771
        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
772
            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
773
            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
774
775
            return $sort_x - $sort_y;
776
        });
777
    }
778
779
    /**
780
     * Allow native PHP functions such as array_unique() to work with objects
781
     *
782
     * @return string
783
     */
784
    public function __toString(): string
785
    {
786
        return $this->id . '@' . $this->record->xref();
787
    }
788
}
789