Passed
Push — master ( b36a1d...70ac1a )
by Greg
07:34
created

Fact::sortFacts()   B

Complexity

Conditions 7
Paths 18

Size

Total Lines 48
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

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