Passed
Push — 2.0 ( b6f7a9...1a919b )
by Greg
11:20
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 . ',' . $parent->xref() . ')');
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 null;
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 null;
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
        // A link to a record of the same type: NOTE=>NOTE, OBJE=>OBJE, SOUR=>SOUR, etc.
328
        // Use the privacy of the target record.
329
        $target = $this->target();
330
331
        if ($target instanceof GedcomRecord && $target->tag() === $this->tag) {
332
            return $target->canShow($access_level);
333
        }
334
335
        // Does this record have a default RESN?
336
        $xref                    = $this->record->xref();
337
        $fact_privacy            = $this->record->tree()->getFactPrivacy();
338
        $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy();
339
        if (isset($individual_fact_privacy[$xref][$this->tag])) {
340
            return $individual_fact_privacy[$xref][$this->tag] >= $access_level;
341
        }
342
        if (isset($fact_privacy[$this->tag])) {
343
            return $fact_privacy[$this->tag] >= $access_level;
344
        }
345
346
        // No restrictions - it must be public
347
        return true;
348
    }
349
350
    /**
351
     * Check whether this fact is protected against edit
352
     *
353
     * @return bool
354
     */
355
    public function canEdit(): bool
356
    {
357
        if ($this->isPendingDeletion()) {
358
            return false;
359
        }
360
361
        if (Auth::isManager($this->record->tree())) {
362
            return true;
363
        }
364
365
        // Members cannot edit RESN, CHAN and locked records
366
        return Auth::isEditor($this->record->tree()) && !str_contains($this->gedcom, "\n2 RESN locked") && $this->tag !== 'RESN' && $this->tag !== 'CHAN';
367
    }
368
369
    /**
370
     * The place where the event occured.
371
     *
372
     * @return Place
373
     */
374
    public function place(): Place
375
    {
376
        if ($this->place === null) {
377
            $this->place = new Place($this->attribute('PLAC'), $this->record()->tree());
378
        }
379
380
        return $this->place;
381
    }
382
383
    /**
384
     * Get the date for this fact.
385
     * We can call this function many times, especially when sorting,
386
     * so keep a copy of the date.
387
     *
388
     * @return Date
389
     */
390
    public function date(): Date
391
    {
392
        if ($this->date === null) {
393
            $this->date = new Date($this->attribute('DATE'));
394
        }
395
396
        return $this->date;
397
    }
398
399
    /**
400
     * The raw GEDCOM data for this fact
401
     *
402
     * @return string
403
     */
404
    public function gedcom(): string
405
    {
406
        return $this->gedcom;
407
    }
408
409
    /**
410
     * Get a (pseudo) primary key for this fact.
411
     *
412
     * @return string
413
     */
414
    public function id(): string
415
    {
416
        return $this->id;
417
    }
418
419
    /**
420
     * What is the tag (type) of this fact, such as BIRT, MARR or DEAT.
421
     *
422
     * @return string
423
     */
424
    public function tag(): string
425
    {
426
        return $this->record->tag() . ':' . $this->tag;
427
    }
428
429
    /**
430
     * What is the tag (type) of this fact, such as BIRT, MARR or DEAT.
431
     *
432
     * @return string
433
     *
434
     * @deprecated since 2.0.5.  Will be removed in 2.1.0
435
     */
436
    public function getTag(): string
437
    {
438
        return $this->tag;
439
    }
440
441
    /**
442
     * Used to convert a real fact (e.g. BIRT) into a close-relative’s fact (e.g. _BIRT_CHIL)
443
     *
444
     * @param string $tag
445
     *
446
     * @return void
447
     *
448
     * @deprecated since 2.0.5.  Will be removed in 2.1.0
449
     */
450
    public function setTag(string $tag): void
451
    {
452
        $this->tag = $tag;
453
    }
454
455
    /**
456
     * The Person/Family record where this Fact came from
457
     *
458
     * @return Individual|Family|Source|Repository|Media|Note|Submitter|Submission|Location|Header|GedcomRecord
459
     */
460
    public function record()
461
    {
462
        return $this->record;
463
    }
464
465
    /**
466
     * Get the name of this fact type, for use as a label.
467
     *
468
     * @return string
469
     */
470
    public function label(): string
471
    {
472
        // Marriages
473
        if ($this->tag() === 'FAM:MARR') {
474
            $element = Registry::elementFactory()->make('FAM:MARR:TYPE');
475
            $type = $this->attribute('TYPE');
476
477
            if ($type !== '') {
478
                return $element->value($type, $this->record->tree());
479
            }
480
        }
481
482
        // Custom FACT/EVEN - with a TYPE
483
        if ($this->tag === 'FACT' || $this->tag === 'EVEN') {
484
            $type = $this->attribute('TYPE');
485
486
            if ($type !== '') {
487
                if (!str_contains($type, '%')) {
488
                    // Allow user-translations of custom types.
489
                    $translated = I18N::translate($type);
490
491
                    if ($translated !== $type) {
492
                        return $translated;
493
                    }
494
                }
495
496
                return e($type);
497
            }
498
        }
499
500
        return Registry::elementFactory()->make($this->tag())->label();
501
    }
502
503
    /**
504
     * This is a newly deleted fact, pending approval.
505
     *
506
     * @return void
507
     */
508
    public function setPendingDeletion(): void
509
    {
510
        $this->pending_deletion = true;
511
        $this->pending_addition = false;
512
    }
513
514
    /**
515
     * Is this a newly deleted fact, pending approval.
516
     *
517
     * @return bool
518
     */
519
    public function isPendingDeletion(): bool
520
    {
521
        return $this->pending_deletion;
522
    }
523
524
    /**
525
     * This is a newly added fact, pending approval.
526
     *
527
     * @return void
528
     */
529
    public function setPendingAddition(): void
530
    {
531
        $this->pending_addition = true;
532
        $this->pending_deletion = false;
533
    }
534
535
    /**
536
     * Is this a newly added fact, pending approval.
537
     *
538
     * @return bool
539
     */
540
    public function isPendingAddition(): bool
541
    {
542
        return $this->pending_addition;
543
    }
544
545
    /**
546
     * Source citations linked to this fact
547
     *
548
     * @return array<string>
549
     */
550
    public function getCitations(): array
551
    {
552
        preg_match_all('/\n(2 SOUR @(' . Gedcom::REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->gedcom(), $matches, PREG_SET_ORDER);
553
        $citations = [];
554
        foreach ($matches as $match) {
555
            $source = Registry::sourceFactory()->make($match[2], $this->record()->tree());
556
            if ($source && $source->canShow()) {
557
                $citations[] = $match[1];
558
            }
559
        }
560
561
        return $citations;
562
    }
563
564
    /**
565
     * Notes (inline and objects) linked to this fact
566
     *
567
     * @return string[]|Note[]
568
     */
569
    public function getNotes(): array
570
    {
571
        $notes = [];
572
        preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->gedcom(), $matches);
573
        foreach ($matches[1] as $match) {
574
            $note = preg_replace("/\n3 CONT ?/", "\n", $match);
575
            if (preg_match('/@(' . Gedcom::REGEX_XREF . ')@/', $note, $nmatch)) {
576
                $note = Registry::noteFactory()->make($nmatch[1], $this->record()->tree());
577
                if ($note && $note->canShow()) {
578
                    // A note object
579
                    $notes[] = $note;
580
                }
581
            } else {
582
                // An inline note
583
                $notes[] = $note;
584
            }
585
        }
586
587
        return $notes;
588
    }
589
590
    /**
591
     * Media objects linked to this fact
592
     *
593
     * @return Media[]
594
     */
595
    public function getMedia(): array
596
    {
597
        $media = [];
598
        preg_match_all('/\n2 OBJE @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom(), $matches);
599
        foreach ($matches[1] as $match) {
600
            $obje = Registry::mediaFactory()->make($match, $this->record()->tree());
601
            if ($obje && $obje->canShow()) {
602
                $media[] = $obje;
603
            }
604
        }
605
606
        return $media;
607
    }
608
609
    /**
610
     * A one-line summary of the fact - for charts, etc.
611
     *
612
     * @return string
613
     */
614
    public function summary(): string
615
    {
616
        $attributes = [];
617
        $target     = $this->target();
618
        if ($target instanceof GedcomRecord) {
619
            $attributes[] = $target->fullName();
620
        } else {
621
            // Fact value
622
            $value = $this->value();
623
            if ($value !== '' && $value !== 'Y') {
624
                $attributes[] = '<span dir="auto">' . e($value) . '</span>';
625
            }
626
            // Fact date
627
            $date = $this->date();
628
            if ($date->isOK()) {
629
                if ($this->record() instanceof Individual && in_array($this->tag, Gedcom::BIRTH_EVENTS, true) && $this->record()->tree()->getPreference('SHOW_PARENTS_AGE')) {
630
                    $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->record(), $date);
631
                } else {
632
                    $attributes[] = $date->display();
633
                }
634
            }
635
            // Fact place
636
            if ($this->place()->gedcomName() !== '') {
637
                $attributes[] = $this->place()->shortName();
638
            }
639
        }
640
641
        $class = 'fact_' . $this->tag;
642
        if ($this->isPendingAddition()) {
643
            $class .= ' wt-new';
644
        } elseif ($this->isPendingDeletion()) {
645
            $class .= ' wt-old';
646
        }
647
648
        return
649
            '<div class="' . $class . '">' .
650
            /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
651
            I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->label(), implode(' — ', $attributes)) .
652
            '</div>';
653
    }
654
655
    /**
656
     * Helper functions to sort facts
657
     *
658
     * @return Closure
659
     */
660
    private static function dateComparator(): Closure
661
    {
662
        return static function (Fact $a, Fact $b): int {
663
            if ($a->date()->isOK() && $b->date()->isOK()) {
664
                // If both events have dates, compare by date
665
                $ret = Date::compare($a->date(), $b->date());
666
667
                if ($ret === 0) {
668
                    // If dates overlap, compare by fact type
669
                    $ret = self::typeComparator()($a, $b);
670
671
                    // If the fact type is also the same, retain the initial order
672
                    if ($ret === 0) {
673
                        $ret = $a->sortOrder <=> $b->sortOrder;
674
                    }
675
                }
676
677
                return $ret;
678
            }
679
680
            // One or both events have no date - retain the initial order
681
            return $a->sortOrder <=> $b->sortOrder;
682
        };
683
    }
684
685
    /**
686
     * Helper functions to sort facts.
687
     *
688
     * @return Closure
689
     */
690
    public static function typeComparator(): Closure
691
    {
692
        static $factsort = [];
693
694
        if ($factsort === []) {
695
            $factsort = array_flip(self::FACT_ORDER);
696
        }
697
698
        return static function (Fact $a, Fact $b) use ($factsort): int {
699
            // Facts from same families stay grouped together
700
            // Keep MARR and DIV from the same families from mixing with events from other FAMs
701
            // Use the original order in which the facts were added
702
            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
703
                return $a->sortOrder - $b->sortOrder;
704
            }
705
706
            $atag = $a->tag;
707
            $btag = $b->tag;
708
709
            // Events not in the above list get mapped onto one that is.
710
            if (!array_key_exists($atag, $factsort)) {
711
                $atag = '_????_';
712
            }
713
714
            if (!array_key_exists($btag, $factsort)) {
715
                $btag = '_????_';
716
            }
717
718
            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
719
            // - Treat dated after BURI facts as BURI instead
720
            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
721
                $atag = 'BURI';
722
            }
723
724
            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
725
                $btag = 'BURI';
726
            }
727
728
            $ret = $factsort[$atag] - $factsort[$btag];
729
730
            // If facts are the same then put dated facts before non-dated facts
731
            if ($ret == 0) {
732
                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
733
                    return -1;
734
                }
735
736
                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
737
                    return 1;
738
                }
739
740
                // If no sorting preference, then keep original ordering
741
                $ret = $a->sortOrder - $b->sortOrder;
742
            }
743
744
            return $ret;
745
        };
746
    }
747
748
    /**
749
     * A multi-key sort
750
     * 1. First divide the facts into two arrays one set with dates and one set without dates
751
     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
752
     * using the compare type function
753
     * 3. Then merge the arrays back into the original array using the compare type function
754
     *
755
     * @param Collection<Fact> $unsorted
756
     *
757
     * @return Collection<Fact>
758
     */
759
    public static function sortFacts(Collection $unsorted): Collection
760
    {
761
        $dated    = [];
762
        $nondated = [];
763
        $sorted   = [];
764
765
        // Split the array into dated and non-dated arrays
766
        $order = 0;
767
768
        foreach ($unsorted as $fact) {
769
            $fact->sortOrder = $order;
770
            $order++;
771
772
            if ($fact->date()->isOK()) {
773
                $dated[] = $fact;
774
            } else {
775
                $nondated[] = $fact;
776
            }
777
        }
778
779
        usort($dated, self::dateComparator());
780
        usort($nondated, self::typeComparator());
781
782
        // Merge the arrays
783
        $dc = count($dated);
784
        $nc = count($nondated);
785
        $i  = 0;
786
        $j  = 0;
787
788
        // while there is anything in the dated array continue merging
789
        while ($i < $dc) {
790
            // compare each fact by type to merge them in order
791
            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
792
                $sorted[] = $nondated[$j];
793
                $j++;
794
            } else {
795
                $sorted[] = $dated[$i];
796
                $i++;
797
            }
798
        }
799
800
        // get anything that might be left in the nondated array
801
        while ($j < $nc) {
802
            $sorted[] = $nondated[$j];
803
            $j++;
804
        }
805
806
        return new Collection($sorted);
807
    }
808
809
    /**
810
     * Sort fact/event tags using the same order that we use for facts.
811
     *
812
     * @param Collection<string> $unsorted
813
     *
814
     * @return Collection<string>
815
     */
816
    public static function sortFactTags(Collection $unsorted): Collection
817
    {
818
        $tag_order = array_flip(self::FACT_ORDER);
819
820
        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
821
            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
822
            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
823
824
            return $sort_x - $sort_y;
825
        });
826
    }
827
828
    /**
829
     * Allow native PHP functions such as array_unique() to work with objects
830
     *
831
     * @return string
832
     */
833
    public function __toString(): string
834
    {
835
        return $this->id . '@' . $this->record->xref();
836
    }
837
}
838