Passed
Push — master ( cbc90a...62b528 )
by Greg
05:04
created

Fact::target()   C

Complexity

Conditions 17
Paths 17

Size

Total Lines 36
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 17
eloc 31
c 0
b 0
f 0
nc 17
nop 0
dl 0
loc 36
rs 5.2166

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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