Issues (165)

app/Fact.php (1 issue)

Labels
Severity
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 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 FACT_ORDER = [
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 ($this->record instanceof Individual && in_array($this->tag, Gedcom::BIRTH_EVENTS, true) && $this->record->tree()->getPreference('SHOW_PARENTS_AGE')) {
546
                    $attributes[] = $date->display() . view('fact-parents-age', ['individual' => $this->record, 'birth_date' => $date]);
547
                } else {
548
                    $attributes[] = $date->display();
549
                }
550
            }
551
            // Fact place
552
            if ($this->place()->gedcomName() !== '') {
553
                $attributes[] = $this->place()->shortName();
554
            }
555
        }
556
557
        $class = 'fact_' . $this->tag;
558
        if ($this->isPendingAddition()) {
559
            $class .= ' wt-new';
560
        } elseif ($this->isPendingDeletion()) {
561
            $class .= ' wt-old';
562
        }
563
564
        $label = '<span class="label">' . $this->label() . '</span>';
565
        $value = '<span class="field" dir="auto">' . implode(' — ', $attributes) . '</span>';
566
567
        /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
568
        return '<div class="' . $class . '">' . I18N::translate('%1$s: %2$s', $label, $value) . '</div>';
569
    }
570
571
    /**
572
     * A one-line summary of the fact - for the clipboard, etc.
573
     *
574
     * @return string
575
     */
576
    public function name(): string
577
    {
578
        $items  = [$this->label()];
579
        $target = $this->target();
580
581
        if ($target instanceof GedcomRecord) {
582
            $items[] = '<bdi>' . $target->fullName() . '</bdi>';
583
        } else {
584
            // Fact value
585
            $value = $this->value();
586
            if ($value !== '' && $value !== 'Y') {
587
                $items[] = '<bdi>' . e($value) . '</bdi>';
588
            }
589
590
            // Fact date
591
            if ($this->date()->isOK()) {
592
                $items[] = $this->date()->minimumDate()->format('%Y');
593
            }
594
595
            // Fact place
596
            if ($this->place()->gedcomName() !== '') {
597
                $items[] = $this->place()->shortName();
598
            }
599
        }
600
601
        return implode(' — ', $items);
602
    }
603
604
    /**
605
     * Helper functions to sort facts
606
     *
607
     * @return Closure(Fact,Fact):int
608
     */
609
    private static function dateComparator(): Closure
610
    {
611
        return static function (Fact $a, Fact $b): int {
612
            if ($a->date()->isOK() && $b->date()->isOK()) {
613
                // If both events have dates, compare by date
614
                $ret = Date::compare($a->date(), $b->date());
615
616
                if ($ret === 0) {
617
                    // If dates overlap, compare by fact type
618
                    $ret = self::typeComparator()($a, $b);
619
620
                    // If the fact type is also the same, retain the initial order
621
                    if ($ret === 0) {
622
                        $ret = $a->sortOrder <=> $b->sortOrder;
623
                    }
624
                }
625
626
                return $ret;
627
            }
628
629
            // One or both events have no date - retain the initial order
630
            return $a->sortOrder <=> $b->sortOrder;
631
        };
632
    }
633
634
    /**
635
     * Helper functions to sort facts.
636
     *
637
     * @return Closure(Fact,Fact):int
638
     */
639
    public static function typeComparator(): Closure
640
    {
641
        static $factsort = [];
642
643
        if ($factsort === []) {
644
            $factsort = array_flip(self::FACT_ORDER);
645
        }
646
647
        return static function (Fact $a, Fact $b) use ($factsort): int {
648
            // Facts from same families stay grouped together
649
            // Keep MARR and DIV from the same families from mixing with events from other FAMs
650
            // Use the original order in which the facts were added
651
            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
652
                return $a->sortOrder <=> $b->sortOrder;
653
            }
654
655
            // NO events sort as the non-event itself.
656
            $atag = $a->tag === 'NO' ? $a->value() : $a->tag;
657
            $btag = $b->tag === 'NO' ? $b->value() : $b->tag;
658
659
            // Events not in the above list get mapped onto one that is.
660
            if (!array_key_exists($atag, $factsort)) {
661
                $atag = '_????_';
662
            }
663
664
            if (!array_key_exists($btag, $factsort)) {
665
                $btag = '_????_';
666
            }
667
668
            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
669
            // - Treat dated after BURI facts as BURI instead
670
            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
671
                $atag = 'BURI';
672
            }
673
674
            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
675
                $btag = 'BURI';
676
            }
677
678
            // If facts are the same then put dated facts before non-dated facts
679
            if ($atag === $btag) {
680
                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
681
                    return -1;
682
                }
683
684
                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
685
                    return 1;
686
                }
687
688
                // If no sorting preference, then keep original ordering
689
                return $a->sortOrder <=> $b->sortOrder;
690
            }
691
692
            return $factsort[$atag] <=> $factsort[$btag];
693
        };
694
    }
695
696
    /**
697
     * A multi-key sort
698
     * 1. First divide the facts into two arrays one set with dates and one set without dates
699
     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
700
     * using the compare type function
701
     * 3. Then merge the arrays back into the original array using the compare type function
702
     *
703
     * @param Collection<int,Fact> $unsorted
704
     *
705
     * @return Collection<int,Fact>
706
     */
707
    public static function sortFacts(Collection $unsorted): Collection
708
    {
709
        $dated    = [];
710
        $nondated = [];
711
        $sorted   = [];
712
713
        // Split the array into dated and non-dated arrays
714
        $order = 0;
715
716
        foreach ($unsorted as $fact) {
717
            $fact->sortOrder = $order;
718
            $order++;
719
720
            if ($fact->date()->isOK()) {
721
                $dated[] = $fact;
722
            } else {
723
                $nondated[] = $fact;
724
            }
725
        }
726
727
        usort($dated, self::dateComparator());
728
        usort($nondated, self::typeComparator());
729
730
        // Merge the arrays
731
        $dc = count($dated);
732
        $nc = count($nondated);
733
        $i  = 0;
734
        $j  = 0;
735
736
        // while there is anything in the dated array continue merging
737
        while ($i < $dc) {
738
            // compare each fact by type to merge them in order
739
            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
740
                $sorted[] = $nondated[$j];
741
                $j++;
742
            } else {
743
                $sorted[] = $dated[$i];
744
                $i++;
745
            }
746
        }
747
748
        // get anything that might be left in the nondated array
749
        while ($j < $nc) {
750
            $sorted[] = $nondated[$j];
751
            $j++;
752
        }
753
754
        return new Collection($sorted);
0 ignored issues
show
$sorted of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $items of Illuminate\Support\Collection::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

754
        return new Collection(/** @scrutinizer ignore-type */ $sorted);
Loading history...
755
    }
756
757
    /**
758
     * Sort fact/event tags using the same order that we use for facts.
759
     *
760
     * @param Collection<int,string> $unsorted
761
     *
762
     * @return Collection<int,string>
763
     */
764
    public static function sortFactTags(Collection $unsorted): Collection
765
    {
766
        $tag_order = array_flip(self::FACT_ORDER);
767
768
        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
769
            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
770
            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
771
772
            return $sort_x - $sort_y;
773
        });
774
    }
775
776
    /**
777
     * Allow native PHP functions such as array_unique() to work with objects
778
     *
779
     * @return string
780
     */
781
    public function __toString(): string
782
    {
783
        return $this->id . '@' . $this->record->xref();
784
    }
785
}
786