Passed
Push — master ( c5fce9...5d0fff )
by Greg
06:47
created

Fact::getTag()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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