Fact   F
last analyzed

Complexity

Total Complexity 114

Size/Duplication

Total Lines 742
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 324
c 1
b 0
f 0
dl 0
loc 742
rs 2
wmc 114

26 Methods

Rating   Name   Duplication   Size   Complexity  
A longitude() 0 9 2
A latitude() 0 9 2
A isPendingAddition() 0 3 1
A setPendingAddition() 0 4 1
A setPendingDeletion() 0 4 1
A isPendingDeletion() 0 3 1
A record() 0 3 1
A canEdit() 0 12 6
A tag() 0 3 1
A date() 0 5 1
A place() 0 5 1
A id() 0 3 1
A gedcom() 0 3 1
A sortFactTags() 0 9 1
A __toString() 0 3 1
B sortFacts() 0 48 7
A name() 0 26 6
D typeComparator() 0 54 20
A dateComparator() 0 22 5
A __construct() 0 9 2
A attribute() 0 9 2
A value() 0 7 2
B label() 0 35 10
D target() 0 40 19
B summary() 0 39 11
B canShow() 0 40 8

How to fix   Complexity   

Complex Class

Complex classes like Fact often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Fact, and based on these observations, apply Extract Interface, too.

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
            return preg_replace("/\n2 CONT ?/", "\n", $match[1]);
206
        }
207
208
        return '';
209
    }
210
211
    /**
212
     * Get the record to which this fact links
213
     *
214
     * @return Family|GedcomRecord|Individual|Location|Media|Note|Repository|Source|Submission|Submitter|null
215
     */
216
    public function target()
217
    {
218
        if (!preg_match('/^@(' . Gedcom::REGEX_XREF . ')@$/', $this->value(), $match)) {
219
            return null;
220
        }
221
222
        $xref = $match[1];
223
224
        switch ($this->tag) {
225
            case 'FAMC':
226
            case 'FAMS':
227
                return Registry::familyFactory()->make($xref, $this->record->tree());
228
            case 'HUSB':
229
            case 'WIFE':
230
            case 'ALIA':
231
            case 'CHIL':
232
            case '_ASSO':
233
                return Registry::individualFactory()->make($xref, $this->record->tree());
234
            case 'ASSO':
235
                return
236
                    Registry::individualFactory()->make($xref, $this->record->tree()) ??
237
                    Registry::submitterFactory()->make($xref, $this->record->tree());
238
            case 'SOUR':
239
                return Registry::sourceFactory()->make($xref, $this->record->tree());
240
            case 'OBJE':
241
                return Registry::mediaFactory()->make($xref, $this->record->tree());
242
            case 'REPO':
243
                return Registry::repositoryFactory()->make($xref, $this->record->tree());
244
            case 'NOTE':
245
                return Registry::noteFactory()->make($xref, $this->record->tree());
246
            case 'ANCI':
247
            case 'DESI':
248
            case 'SUBM':
249
                return Registry::submitterFactory()->make($xref, $this->record->tree());
250
            case 'SUBN':
251
                return Registry::submissionFactory()->make($xref, $this->record->tree());
252
            case '_LOC':
253
                return Registry::locationFactory()->make($xref, $this->record->tree());
254
            default:
255
                return Registry::gedcomRecordFactory()->make($xref, $this->record->tree());
256
        }
257
    }
258
259
    /**
260
     * Get the value of level 2 data in the fact
261
     *
262
     * @param string $tag
263
     *
264
     * @return string
265
     */
266
    public function attribute(string $tag): string
267
    {
268
        if (preg_match('/\n2 ' . $tag . '\b ?(.*(?:(?:\n3 CONT ?.*)*)*)/', $this->gedcom, $match)) {
269
            $value = preg_replace("/\n3 CONT ?/", "\n", $match[1]);
270
271
            return Registry::elementFactory()->make($this->tag() . ':' . $tag)->canonical($value);
272
        }
273
274
        return '';
275
    }
276
277
    /**
278
     * Get the PLAC:MAP:LATI for the fact.
279
     *
280
     * @return float|null
281
     */
282
    public function latitude(): ?float
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
     * @return float|null
297
     */
298
    public function longitude(): ?float
299
    {
300
        if (preg_match('/\n4 LONG (.+)/', $this->gedcom, $match)) {
301
            $gedcom_service = new GedcomService();
302
303
            return $gedcom_service->readLongitude($match[1]);
304
        }
305
306
        return null;
307
    }
308
309
    /**
310
     * Do the privacy rules allow us to display this fact to the current user
311
     *
312
     * @param int|null $access_level
313
     *
314
     * @return bool
315
     */
316
    public function canShow(?int $access_level = null): bool
317
    {
318
        $access_level ??= Auth::accessLevel($this->record->tree());
319
320
        // Does this record have an explicit restriction notice?
321
        $element     = new RestrictionNotice('');
322
        $restriction = $element->canonical($this->attribute('RESN'));
323
324
        if (str_starts_with($restriction, RestrictionNotice::VALUE_CONFIDENTIAL)) {
325
            return Auth::PRIV_NONE >= $access_level;
326
        }
327
328
        if (str_starts_with($restriction, RestrictionNotice::VALUE_PRIVACY)) {
329
            return Auth::PRIV_USER >= $access_level;
330
        }
331
        if (str_starts_with($restriction, RestrictionNotice::VALUE_NONE)) {
332
            return true;
333
        }
334
335
        // A link to a record of the same type: NOTE=>NOTE, OBJE=>OBJE, SOUR=>SOUR, etc.
336
        // Use the privacy of the target record.
337
        $target = $this->target();
338
339
        if ($target instanceof GedcomRecord && $target->tag() === $this->tag) {
340
            return $target->canShow($access_level);
341
        }
342
343
        // Does this record have a default RESN?
344
        $xref                    = $this->record->xref();
345
        $fact_privacy            = $this->record->tree()->getFactPrivacy();
346
        $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy();
347
        if (isset($individual_fact_privacy[$xref][$this->tag])) {
348
            return $individual_fact_privacy[$xref][$this->tag] >= $access_level;
349
        }
350
        if (isset($fact_privacy[$this->tag])) {
351
            return $fact_privacy[$this->tag] >= $access_level;
352
        }
353
354
        // No restrictions - it must be public
355
        return true;
356
    }
357
358
    /**
359
     * Check whether this fact is protected against edit
360
     *
361
     * @return bool
362
     */
363
    public function canEdit(): bool
364
    {
365
        if ($this->isPendingDeletion()) {
366
            return false;
367
        }
368
369
        if (Auth::isManager($this->record->tree())) {
370
            return true;
371
        }
372
373
        // Members cannot edit RESN, CHAN and locked records
374
        return Auth::isEditor($this->record->tree()) && !str_ends_with($this->attribute('RESN'), RestrictionNotice::VALUE_LOCKED) && $this->tag !== 'RESN' && $this->tag !== 'CHAN';
375
    }
376
377
    /**
378
     * The place where the event occurred.
379
     *
380
     * @return Place
381
     */
382
    public function place(): Place
383
    {
384
        $this->place ??= new Place($this->attribute('PLAC'), $this->record->tree());
385
386
        return $this->place;
387
    }
388
389
    /**
390
     * Get the date for this fact.
391
     * We can call this function many times, especially when sorting,
392
     * so keep a copy of the date.
393
     *
394
     * @return Date
395
     */
396
    public function date(): Date
397
    {
398
        $this->date ??= new Date($this->attribute('DATE'));
399
400
        return $this->date;
401
    }
402
403
    /**
404
     * The raw GEDCOM data for this fact
405
     *
406
     * @return string
407
     */
408
    public function gedcom(): string
409
    {
410
        return $this->gedcom;
411
    }
412
413
    /**
414
     * Get a (pseudo) primary key for this fact.
415
     *
416
     * @return string
417
     */
418
    public function id(): string
419
    {
420
        return $this->id;
421
    }
422
423
    /**
424
     * What is the tag (type) of this fact, such as BIRT, MARR or DEAT.
425
     *
426
     * @return string
427
     */
428
    public function tag(): string
429
    {
430
        return $this->record->tag() . ':' . $this->tag;
431
    }
432
433
    /**
434
     * The GEDCOM record where this Fact came from
435
     *
436
     * @return GedcomRecord
437
     */
438
    public function record(): GedcomRecord
439
    {
440
        return $this->record;
441
    }
442
443
    /**
444
     * Get the name of this fact type, for use as a label.
445
     *
446
     * @return string
447
     */
448
    public function label(): string
449
    {
450
        if (str_ends_with($this->tag(), ':NOTE') && preg_match('/^@' . Gedcom::REGEX_XREF . '@$/', $this->value())) {
451
            return I18N::translate('Shared note');
452
        }
453
454
        // Marriages
455
        if ($this->tag() === 'FAM:MARR') {
456
            $element = Registry::elementFactory()->make('FAM:MARR:TYPE');
457
            $type = $this->attribute('TYPE');
458
459
            if ($type !== '') {
460
                return $element->value($type, $this->record->tree());
461
            }
462
        }
463
464
        // Custom FACT/EVEN - with a TYPE
465
        if ($this->tag === 'FACT' || $this->tag === 'EVEN') {
466
            $type = $this->attribute('TYPE');
467
468
            if ($type !== '') {
469
                if (!str_contains($type, '%')) {
470
                    // Allow user-translations of custom types.
471
                    $translated = I18N::translate($type);
472
473
                    if ($translated !== $type) {
474
                        return $translated;
475
                    }
476
                }
477
478
                return e($type);
479
            }
480
        }
481
482
        return Registry::elementFactory()->make($this->tag())->label();
483
    }
484
485
    /**
486
     * This is a newly deleted fact, pending approval.
487
     *
488
     * @return void
489
     */
490
    public function setPendingDeletion(): void
491
    {
492
        $this->pending_deletion = true;
493
        $this->pending_addition = false;
494
    }
495
496
    /**
497
     * Is this a newly deleted fact, pending approval.
498
     *
499
     * @return bool
500
     */
501
    public function isPendingDeletion(): bool
502
    {
503
        return $this->pending_deletion;
504
    }
505
506
    /**
507
     * This is a newly added fact, pending approval.
508
     *
509
     * @return void
510
     */
511
    public function setPendingAddition(): void
512
    {
513
        $this->pending_addition = true;
514
        $this->pending_deletion = false;
515
    }
516
517
    /**
518
     * Is this a newly added fact, pending approval.
519
     *
520
     * @return bool
521
     */
522
    public function isPendingAddition(): bool
523
    {
524
        return $this->pending_addition;
525
    }
526
527
    /**
528
     * A one-line summary of the fact - for charts, etc.
529
     *
530
     * @return string
531
     */
532
    public function summary(): string
533
    {
534
        $attributes = [];
535
        $target     = $this->target();
536
        if ($target instanceof GedcomRecord) {
537
            $attributes[] = $target->fullName();
538
        } else {
539
            // Fact value
540
            $value = $this->value();
541
            if ($value !== '' && $value !== 'Y') {
542
                $attributes[] = '<bdi>' . e($value) . '</bdi>';
543
            }
544
            // Fact date
545
            $date = $this->date();
546
            if ($date->isOK()) {
547
                if ($this->record instanceof Individual && in_array($this->tag, Gedcom::BIRTH_EVENTS, true) && $this->record->tree()->getPreference('SHOW_PARENTS_AGE')) {
548
                    $attributes[] = $date->display() . view('fact-parents-age', ['individual' => $this->record, 'birth_date' => $date]);
549
                } else {
550
                    $attributes[] = $date->display();
551
                }
552
            }
553
            // Fact place
554
            if ($this->place()->gedcomName() !== '') {
555
                $attributes[] = $this->place()->shortName();
556
            }
557
        }
558
559
        $class = 'fact_' . $this->tag;
560
        if ($this->isPendingAddition()) {
561
            $class .= ' wt-new';
562
        } elseif ($this->isPendingDeletion()) {
563
            $class .= ' wt-old';
564
        }
565
566
        $label = '<span class="label">' . $this->label() . '</span>';
567
        $value = '<span class="field" dir="auto">' . implode(' — ', $attributes) . '</span>';
568
569
        /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
570
        return '<div class="' . $class . '">' . I18N::translate('%1$s: %2$s', $label, $value) . '</div>';
571
    }
572
573
    /**
574
     * A one-line summary of the fact - for the clipboard, etc.
575
     *
576
     * @return string
577
     */
578
    public function name(): string
579
    {
580
        $items  = [$this->label()];
581
        $target = $this->target();
582
583
        if ($target instanceof GedcomRecord) {
584
            $items[] = '<bdi>' . $target->fullName() . '</bdi>';
585
        } else {
586
            // Fact value
587
            $value = $this->value();
588
            if ($value !== '' && $value !== 'Y') {
589
                $items[] = '<bdi>' . e($value) . '</bdi>';
590
            }
591
592
            // Fact date
593
            if ($this->date()->isOK()) {
594
                $items[] = $this->date()->minimumDate()->format('%Y');
595
            }
596
597
            // Fact place
598
            if ($this->place()->gedcomName() !== '') {
599
                $items[] = $this->place()->shortName();
600
            }
601
        }
602
603
        return implode(' — ', $items);
604
    }
605
606
    /**
607
     * Helper functions to sort facts
608
     *
609
     * @return Closure(Fact,Fact):int
610
     */
611
    private static function dateComparator(): Closure
612
    {
613
        return static function (Fact $a, Fact $b): int {
614
            if ($a->date()->isOK() && $b->date()->isOK()) {
615
                // If both events have dates, compare by date
616
                $ret = Date::compare($a->date(), $b->date());
617
618
                if ($ret === 0) {
619
                    // If dates overlap, compare by fact type
620
                    $ret = self::typeComparator()($a, $b);
621
622
                    // If the fact type is also the same, retain the initial order
623
                    if ($ret === 0) {
624
                        $ret = $a->sortOrder <=> $b->sortOrder;
625
                    }
626
                }
627
628
                return $ret;
629
            }
630
631
            // One or both events have no date - retain the initial order
632
            return $a->sortOrder <=> $b->sortOrder;
633
        };
634
    }
635
636
    /**
637
     * Helper functions to sort facts.
638
     *
639
     * @return Closure(Fact,Fact):int
640
     */
641
    public static function typeComparator(): Closure
642
    {
643
        static $factsort = [];
644
645
        if ($factsort === []) {
646
            $factsort = array_flip(self::FACT_ORDER);
647
        }
648
649
        return static function (Fact $a, Fact $b) use ($factsort): int {
650
            // Facts from same families stay grouped together
651
            // Keep MARR and DIV from the same families from mixing with events from other FAMs
652
            // Use the original order in which the facts were added
653
            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
654
                return $a->sortOrder <=> $b->sortOrder;
655
            }
656
657
            // NO events sort as the non-event itself.
658
            $atag = $a->tag === 'NO' ? $a->value() : $a->tag;
659
            $btag = $b->tag === 'NO' ? $b->value() : $b->tag;
660
661
            // Events not in the above list get mapped onto one that is.
662
            if (!array_key_exists($atag, $factsort)) {
663
                $atag = '_????_';
664
            }
665
666
            if (!array_key_exists($btag, $factsort)) {
667
                $btag = '_????_';
668
            }
669
670
            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
671
            // - Treat dated after BURI facts as BURI instead
672
            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
673
                $atag = 'BURI';
674
            }
675
676
            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
677
                $btag = 'BURI';
678
            }
679
680
            // If facts are the same then put dated facts before non-dated facts
681
            if ($atag === $btag) {
682
                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
683
                    return -1;
684
                }
685
686
                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
687
                    return 1;
688
                }
689
690
                // If no sorting preference, then keep original ordering
691
                return $a->sortOrder <=> $b->sortOrder;
692
            }
693
694
            return $factsort[$atag] <=> $factsort[$btag];
695
        };
696
    }
697
698
    /**
699
     * A multi-key sort
700
     * 1. First divide the facts into two arrays one set with dates and one set without dates
701
     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
702
     * using the compare type function
703
     * 3. Then merge the arrays back into the original array using the compare type function
704
     *
705
     * @param Collection<int,Fact> $unsorted
706
     *
707
     * @return Collection<int,Fact>
708
     */
709
    public static function sortFacts(Collection $unsorted): Collection
710
    {
711
        $dated    = [];
712
        $nondated = [];
713
        $sorted   = [];
714
715
        // Split the array into dated and non-dated arrays
716
        $order = 0;
717
718
        foreach ($unsorted as $fact) {
719
            $fact->sortOrder = $order;
720
            $order++;
721
722
            if ($fact->date()->isOK()) {
723
                $dated[] = $fact;
724
            } else {
725
                $nondated[] = $fact;
726
            }
727
        }
728
729
        usort($dated, self::dateComparator());
730
        usort($nondated, self::typeComparator());
731
732
        // Merge the arrays
733
        $dc = count($dated);
734
        $nc = count($nondated);
735
        $i  = 0;
736
        $j  = 0;
737
738
        // while there is anything in the dated array continue merging
739
        while ($i < $dc) {
740
            // compare each fact by type to merge them in order
741
            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
742
                $sorted[] = $nondated[$j];
743
                $j++;
744
            } else {
745
                $sorted[] = $dated[$i];
746
                $i++;
747
            }
748
        }
749
750
        // get anything that might be left in the nondated array
751
        while ($j < $nc) {
752
            $sorted[] = $nondated[$j];
753
            $j++;
754
        }
755
756
        return new Collection($sorted);
757
    }
758
759
    /**
760
     * Sort fact/event tags using the same order that we use for facts.
761
     *
762
     * @param Collection<int,string> $unsorted
763
     *
764
     * @return Collection<int,string>
765
     */
766
    public static function sortFactTags(Collection $unsorted): Collection
767
    {
768
        $tag_order = array_flip(self::FACT_ORDER);
769
770
        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
771
            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
772
            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
773
774
            return $sort_x - $sort_y;
775
        });
776
    }
777
778
    /**
779
     * Allow native PHP functions such as array_unique() to work with objects
780
     *
781
     * @return string
782
     */
783
    public function __toString(): string
784
    {
785
        return $this->id . '@' . $this->record->xref();
786
    }
787
}
788