Completed
Push — master ( 8b2d6d...8091bf )
by Greg
07:37
created

GedcomRecord::lock()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 0
dl 0
loc 7
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2020 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 Exception;
24
use Fisharebest\Webtrees\Functions\FunctionsPrint;
25
use Fisharebest\Webtrees\Http\RequestHandlers\GedcomRecordPage;
26
use Fisharebest\Webtrees\Services\PendingChangesService;
27
use Illuminate\Database\Capsule\Manager as DB;
28
use Illuminate\Database\Query\Builder;
29
use Illuminate\Database\Query\Expression;
30
use Illuminate\Database\Query\JoinClause;
31
use Illuminate\Support\Collection;
32
use Throwable;
33
use Transliterator;
34
35
use function addcslashes;
36
use function app;
37
use function array_shift;
38
use function assert;
39
use function count;
40
use function date;
1 ignored issue
show
Bug introduced by
This use statement conflicts with another class in this namespace, Fisharebest\Webtrees\date. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
41
use function e;
42
use function explode;
43
use function in_array;
44
use function md5;
45
use function preg_match;
46
use function preg_match_all;
47
use function preg_replace;
48
use function preg_replace_callback;
49
use function preg_split;
50
use function route;
51
use function str_contains;
52
use function str_pad;
53
use function strip_tags;
54
use function strtoupper;
55
use function trim;
56
57
use const PREG_SET_ORDER;
58
use const STR_PAD_LEFT;
59
60
/**
61
 * A GEDCOM object.
62
 */
63
class GedcomRecord
64
{
65
    public const RECORD_TYPE = 'UNKNOWN';
66
67
    protected const ROUTE_NAME = GedcomRecordPage::class;
68
69
    /** @var string The record identifier */
70
    protected $xref;
71
72
    /** @var Tree  The family tree to which this record belongs */
73
    protected $tree;
74
75
    /** @var string  GEDCOM data (before any pending edits) */
76
    protected $gedcom;
77
78
    /** @var string|null  GEDCOM data (after any pending edits) */
79
    protected $pending;
80
81
    /** @var Fact[] facts extracted from $gedcom/$pending */
82
    protected $facts;
83
84
    /** @var string[][] All the names of this individual */
85
    protected $getAllNames;
86
87
    /** @var int|null Cached result */
88
    protected $getPrimaryName;
89
    /** @var int|null Cached result */
90
    protected $getSecondaryName;
91
92
    /**
93
     * Create a GedcomRecord object from raw GEDCOM data.
94
     *
95
     * @param string      $xref
96
     * @param string      $gedcom  an empty string for new/pending records
97
     * @param string|null $pending null for a record with no pending edits,
98
     *                             empty string for records with pending deletions
99
     * @param Tree        $tree
100
     */
101
    public function __construct(string $xref, string $gedcom, ?string $pending, Tree $tree)
102
    {
103
        $this->xref    = $xref;
104
        $this->gedcom  = $gedcom;
105
        $this->pending = $pending;
106
        $this->tree    = $tree;
107
108
        $this->parseFacts();
109
    }
110
111
    /**
112
     * A closure which will create a record from a database row.
113
     *
114
     * @deprecated since 2.0.4.  Will be removed in 2.1.0 - Use Factory::gedcomRecord()
115
     *
116
     * @param Tree $tree
117
     *
118
     * @return Closure
119
     */
120
    public static function rowMapper(Tree $tree): Closure
121
    {
122
        return Registry::gedcomRecordFactory()->mapper($tree);
123
    }
124
125
    /**
126
     * A closure which will filter out private records.
127
     *
128
     * @return Closure
129
     */
130
    public static function accessFilter(): Closure
131
    {
132
        return static function (GedcomRecord $record): bool {
133
            return $record->canShow();
134
        };
135
    }
136
137
    /**
138
     * A closure which will compare records by name.
139
     *
140
     * @return Closure
141
     */
142
    public static function nameComparator(): Closure
143
    {
144
        return static function (GedcomRecord $x, GedcomRecord $y): int {
145
            if ($x->canShowName()) {
146
                if ($y->canShowName()) {
147
                    return I18N::strcasecmp($x->sortName(), $y->sortName());
148
                }
149
150
                return -1; // only $y is private
151
            }
152
153
            if ($y->canShowName()) {
154
                return 1; // only $x is private
155
            }
156
157
            return 0; // both $x and $y private
158
        };
159
    }
160
161
    /**
162
     * A closure which will compare records by change time.
163
     *
164
     * @param int $direction +1 to sort ascending, -1 to sort descending
165
     *
166
     * @return Closure
167
     */
168
    public static function lastChangeComparator(int $direction = 1): Closure
169
    {
170
        return static function (GedcomRecord $x, GedcomRecord $y) use ($direction): int {
171
            return $direction * ($x->lastChangeTimestamp() <=> $y->lastChangeTimestamp());
172
        };
173
    }
174
175
    /**
176
     * Get an instance of a GedcomRecord object. For single records,
177
     * we just receive the XREF. For bulk records (such as lists
178
     * and search results) we can receive the GEDCOM data as well.
179
     *
180
     * @deprecated since 2.0.4.  Will be removed in 2.1.0 - Use Factory::gedcomRecord()
181
     *
182
     * @param string      $xref
183
     * @param Tree        $tree
184
     * @param string|null $gedcom
185
     *
186
     * @return GedcomRecord|Individual|Family|Source|Repository|Media|Note|Submitter|null
187
     */
188
    public static function getInstance(string $xref, Tree $tree, string $gedcom = null)
189
    {
190
        return Registry::gedcomRecordFactory()->make($xref, $tree, $gedcom);
191
    }
192
193
    /**
194
     * Get the GEDCOM tag for this record.
195
     *
196
     * @return string
197
     */
198
    public function tag(): string
199
    {
200
        preg_match('/^0 @[^@]*@ (\w+)/', $this->gedcom(), $match);
201
202
        return $match[1] ?? static::RECORD_TYPE;
203
    }
204
205
    /**
206
     * Get the XREF for this record
207
     *
208
     * @return string
209
     */
210
    public function xref(): string
211
    {
212
        return $this->xref;
213
    }
214
215
    /**
216
     * Get the tree to which this record belongs
217
     *
218
     * @return Tree
219
     */
220
    public function tree(): Tree
221
    {
222
        return $this->tree;
223
    }
224
225
    /**
226
     * Application code should access data via Fact objects.
227
     * This function exists to support old code.
228
     *
229
     * @return string
230
     */
231
    public function gedcom(): string
232
    {
233
        return $this->pending ?? $this->gedcom;
234
    }
235
236
    /**
237
     * Does this record have a pending change?
238
     *
239
     * @return bool
240
     */
241
    public function isPendingAddition(): bool
242
    {
243
        return $this->pending !== null;
244
    }
245
246
    /**
247
     * Does this record have a pending deletion?
248
     *
249
     * @return bool
250
     */
251
    public function isPendingDeletion(): bool
252
    {
253
        return $this->pending === '';
254
    }
255
256
    /**
257
     * Generate a "slug" to use in pretty URLs.
258
     *
259
     * @return string
260
     */
261
    public function slug(): string
262
    {
263
        $slug = strip_tags($this->fullName());
264
265
        try {
266
            $transliterator = Transliterator::create('Any-Latin;Latin-ASCII');
267
            $slug           = $transliterator->transliterate($slug);
268
        } catch (Throwable $ex) {
269
            // ext-intl not installed?
270
            // Transliteration algorithms not present in lib-icu?
271
        }
272
273
        $slug = preg_replace('/[^A-Za-z0-9]+/', '-', $slug);
274
275
        return trim($slug, '-') ?: '-';
276
    }
277
278
    /**
279
     * Generate a URL to this record.
280
     *
281
     * @return string
282
     */
283
    public function url(): string
284
    {
285
        return route(static::ROUTE_NAME, [
286
            'xref' => $this->xref(),
287
            'tree' => $this->tree->name(),
288
            'slug' => $this->slug(),
289
        ]);
290
    }
291
292
    /**
293
     * Can the details of this record be shown?
294
     *
295
     * @param int|null $access_level
296
     *
297
     * @return bool
298
     */
299
    public function canShow(int $access_level = null): bool
300
    {
301
        $access_level = $access_level ?? Auth::accessLevel($this->tree);
302
303
        // We use this value to bypass privacy checks. For example,
304
        // when downloading data or when calculating privacy itself.
305
        if ($access_level === Auth::PRIV_HIDE) {
306
            return true;
307
        }
308
309
        $cache_key = 'show-' . $this->xref . '-' . $this->tree->id() . '-' . $access_level;
310
311
        return Registry::cache()->array()->remember($cache_key, function () use ($access_level) {
312
            return $this->canShowRecord($access_level);
313
        });
314
    }
315
316
    /**
317
     * Can the name of this record be shown?
318
     *
319
     * @param int|null $access_level
320
     *
321
     * @return bool
322
     */
323
    public function canShowName(int $access_level = null): bool
324
    {
325
        return $this->canShow($access_level);
326
    }
327
328
    /**
329
     * Can we edit this record?
330
     *
331
     * @return bool
332
     */
333
    public function canEdit(): bool
334
    {
335
        if ($this->isPendingDeletion()) {
336
            return false;
337
        }
338
339
        if (Auth::isManager($this->tree)) {
340
            return true;
341
        }
342
343
        return Auth::isEditor($this->tree) && !str_contains($this->gedcom, "\n1 RESN locked");
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

343
        return Auth::isEditor($this->tree) && !/** @scrutinizer ignore-deprecated */ str_contains($this->gedcom, "\n1 RESN locked");

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
344
    }
345
346
    /**
347
     * Remove private data from the raw gedcom record.
348
     * Return both the visible and invisible data. We need the invisible data when editing.
349
     *
350
     * @param int $access_level
351
     *
352
     * @return string
353
     */
354
    public function privatizeGedcom(int $access_level): string
355
    {
356
        if ($access_level === Auth::PRIV_HIDE) {
357
            // We may need the original record, for example when downloading a GEDCOM or clippings cart
358
            return $this->gedcom;
359
        }
360
361
        if ($this->canShow($access_level)) {
362
            // The record is not private, but the individual facts may be.
363
364
            // Include the entire first line (for NOTE records)
365
            [$gedrec] = explode("\n", $this->gedcom . $this->pending, 2);
366
367
            // Check each of the facts for access
368
            foreach ($this->facts([], false, $access_level) as $fact) {
369
                $gedrec .= "\n" . $fact->gedcom();
370
            }
371
372
            return $gedrec;
373
        }
374
375
        // We cannot display the details, but we may be able to display
376
        // limited data, such as links to other records.
377
        return $this->createPrivateGedcomRecord($access_level);
378
    }
379
380
    /**
381
     * Default for "other" object types
382
     *
383
     * @return void
384
     */
385
    public function extractNames(): void
386
    {
387
        $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
388
    }
389
390
    /**
391
     * Derived classes should redefine this function, otherwise the object will have no name
392
     *
393
     * @return string[][]
394
     */
395
    public function getAllNames(): array
396
    {
397
        if ($this->getAllNames === null) {
398
            $this->getAllNames = [];
399
            if ($this->canShowName()) {
400
                // Ask the record to extract its names
401
                $this->extractNames();
402
                // No name found? Use a fallback.
403
                if ($this->getAllNames === []) {
404
                    $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
405
                }
406
            } else {
407
                $this->addName(static::RECORD_TYPE, I18N::translate('Private'), '');
408
            }
409
        }
410
411
        return $this->getAllNames;
412
    }
413
414
    /**
415
     * If this object has no name, what do we call it?
416
     *
417
     * @return string
418
     */
419
    public function getFallBackName(): string
420
    {
421
        return e($this->xref());
422
    }
423
424
    /**
425
     * Which of the (possibly several) names of this record is the primary one.
426
     *
427
     * @return int
428
     */
429
    public function getPrimaryName(): int
430
    {
431
        static $language_script;
432
433
        if ($language_script === null) {
434
            $language_script = $language_script ?? I18N::locale()->script()->code();
435
        }
436
437
        if ($this->getPrimaryName === null) {
438
            // Generally, the first name is the primary one....
439
            $this->getPrimaryName = 0;
440
            // ...except when the language/name use different character sets
441
            foreach ($this->getAllNames() as $n => $name) {
442
                if (I18N::textScript($name['sort']) === $language_script) {
443
                    $this->getPrimaryName = $n;
444
                    break;
445
                }
446
            }
447
        }
448
449
        return $this->getPrimaryName;
450
    }
451
452
    /**
453
     * Which of the (possibly several) names of this record is the secondary one.
454
     *
455
     * @return int
456
     */
457
    public function getSecondaryName(): int
458
    {
459
        if ($this->getSecondaryName === null) {
460
            // Generally, the primary and secondary names are the same
461
            $this->getSecondaryName = $this->getPrimaryName();
462
            // ....except when there are names with different character sets
463
            $all_names = $this->getAllNames();
464
            if (count($all_names) > 1) {
465
                $primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']);
466
                foreach ($all_names as $n => $name) {
467
                    if ($n !== $this->getPrimaryName() && $name['type'] !== '_MARNM' && I18N::textScript($name['sort']) !== $primary_script) {
468
                        $this->getSecondaryName = $n;
469
                        break;
470
                    }
471
                }
472
            }
473
        }
474
475
        return $this->getSecondaryName;
476
    }
477
478
    /**
479
     * Allow the choice of primary name to be overidden, e.g. in a search result
480
     *
481
     * @param int|null $n
482
     *
483
     * @return void
484
     */
485
    public function setPrimaryName(int $n = null): void
486
    {
487
        $this->getPrimaryName   = $n;
488
        $this->getSecondaryName = null;
489
    }
490
491
    /**
492
     * Allow native PHP functions such as array_unique() to work with objects
493
     *
494
     * @return string
495
     */
496
    public function __toString()
497
    {
498
        return $this->xref . '@' . $this->tree->id();
499
    }
500
501
    /**
502
     * /**
503
     * Get variants of the name
504
     *
505
     * @return string
506
     */
507
    public function fullName(): string
508
    {
509
        if ($this->canShowName()) {
510
            $tmp = $this->getAllNames();
511
512
            return $tmp[$this->getPrimaryName()]['full'];
513
        }
514
515
        return I18N::translate('Private');
516
    }
517
518
    /**
519
     * Get a sortable version of the name. Do not display this!
520
     *
521
     * @return string
522
     */
523
    public function sortName(): string
524
    {
525
        // The sortable name is never displayed, no need to call canShowName()
526
        $tmp = $this->getAllNames();
527
528
        return $tmp[$this->getPrimaryName()]['sort'];
529
    }
530
531
    /**
532
     * Get the full name in an alternative character set
533
     *
534
     * @return string|null
535
     */
536
    public function alternateName(): ?string
537
    {
538
        if ($this->canShowName() && $this->getPrimaryName() !== $this->getSecondaryName()) {
539
            $all_names = $this->getAllNames();
540
541
            return $all_names[$this->getSecondaryName()]['full'];
542
        }
543
544
        return null;
545
    }
546
547
    /**
548
     * Format this object for display in a list
549
     *
550
     * @return string
551
     */
552
    public function formatList(): string
553
    {
554
        $html = '<a href="' . e($this->url()) . '" class="list_item">';
555
        $html .= '<b>' . $this->fullName() . '</b>';
556
        $html .= $this->formatListDetails();
557
        $html .= '</a>';
558
559
        return $html;
560
    }
561
562
    /**
563
     * This function should be redefined in derived classes to show any major
564
     * identifying characteristics of this record.
565
     *
566
     * @return string
567
     */
568
    public function formatListDetails(): string
569
    {
570
        return '';
571
    }
572
573
    /**
574
     * Extract/format the first fact from a list of facts.
575
     *
576
     * @param string[] $facts
577
     * @param int      $style
578
     *
579
     * @return string
580
     */
581
    public function formatFirstMajorFact(array $facts, int $style): string
582
    {
583
        foreach ($this->facts($facts, true) as $event) {
584
            // Only display if it has a date or place (or both)
585
            if ($event->date()->isOK() && $event->place()->gedcomName() !== '') {
586
                $joiner = ' — ';
587
            } else {
588
                $joiner = '';
589
            }
590
            if ($event->date()->isOK() || $event->place()->gedcomName() !== '') {
591
                switch ($style) {
592
                    case 1:
593
                        return '<br><em>' . $event->label() . ' ' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</em>';
594
                    case 2:
595
                        return '<dl><dt class="label">' . $event->label() . '</dt><dd class="field">' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</dd></dl>';
596
                }
597
            }
598
        }
599
600
        return '';
601
    }
602
603
    /**
604
     * Find individuals linked to this record.
605
     *
606
     * @param string $link
607
     *
608
     * @return Collection<Individual>
609
     */
610
    public function linkedIndividuals(string $link): Collection
611
    {
612
        return DB::table('individuals')
613
            ->join('link', static function (JoinClause $join): void {
614
                $join
615
                    ->on('l_file', '=', 'i_file')
616
                    ->on('l_from', '=', 'i_id');
617
            })
618
            ->where('i_file', '=', $this->tree->id())
619
            ->where('l_type', '=', $link)
620
            ->where('l_to', '=', $this->xref)
621
            ->select(['individuals.*'])
622
            ->get()
623
            ->map(Registry::individualFactory()->mapper($this->tree))
624
            ->filter(self::accessFilter());
625
    }
626
627
    /**
628
     * Find families linked to this record.
629
     *
630
     * @param string $link
631
     *
632
     * @return Collection<Family>
633
     */
634
    public function linkedFamilies(string $link): Collection
635
    {
636
        return DB::table('families')
637
            ->join('link', static function (JoinClause $join): void {
638
                $join
639
                    ->on('l_file', '=', 'f_file')
640
                    ->on('l_from', '=', 'f_id');
641
            })
642
            ->where('f_file', '=', $this->tree->id())
643
            ->where('l_type', '=', $link)
644
            ->where('l_to', '=', $this->xref)
645
            ->select(['families.*'])
646
            ->get()
647
            ->map(Registry::familyFactory()->mapper($this->tree))
648
            ->filter(self::accessFilter());
649
    }
650
651
    /**
652
     * Find sources linked to this record.
653
     *
654
     * @param string $link
655
     *
656
     * @return Collection<Source>
657
     */
658
    public function linkedSources(string $link): Collection
659
    {
660
        return DB::table('sources')
661
            ->join('link', static function (JoinClause $join): void {
662
                $join
663
                    ->on('l_file', '=', 's_file')
664
                    ->on('l_from', '=', 's_id');
665
            })
666
            ->where('s_file', '=', $this->tree->id())
667
            ->where('l_type', '=', $link)
668
            ->where('l_to', '=', $this->xref)
669
            ->select(['sources.*'])
670
            ->get()
671
            ->map(Registry::sourceFactory()->mapper($this->tree))
672
            ->filter(self::accessFilter());
673
    }
674
675
    /**
676
     * Find media objects linked to this record.
677
     *
678
     * @param string $link
679
     *
680
     * @return Collection<Media>
681
     */
682
    public function linkedMedia(string $link): Collection
683
    {
684
        return DB::table('media')
685
            ->join('link', static function (JoinClause $join): void {
686
                $join
687
                    ->on('l_file', '=', 'm_file')
688
                    ->on('l_from', '=', 'm_id');
689
            })
690
            ->where('m_file', '=', $this->tree->id())
691
            ->where('l_type', '=', $link)
692
            ->where('l_to', '=', $this->xref)
693
            ->select(['media.*'])
694
            ->get()
695
            ->map(Registry::mediaFactory()->mapper($this->tree))
696
            ->filter(self::accessFilter());
697
    }
698
699
    /**
700
     * Find notes linked to this record.
701
     *
702
     * @param string $link
703
     *
704
     * @return Collection<Note>
705
     */
706
    public function linkedNotes(string $link): Collection
707
    {
708
        return DB::table('other')
709
            ->join('link', static function (JoinClause $join): void {
710
                $join
711
                    ->on('l_file', '=', 'o_file')
712
                    ->on('l_from', '=', 'o_id');
713
            })
714
            ->where('o_file', '=', $this->tree->id())
715
            ->where('o_type', '=', Note::RECORD_TYPE)
716
            ->where('l_type', '=', $link)
717
            ->where('l_to', '=', $this->xref)
718
            ->select(['other.*'])
719
            ->get()
720
            ->map(Registry::noteFactory()->mapper($this->tree))
721
            ->filter(self::accessFilter());
722
    }
723
724
    /**
725
     * Find repositories linked to this record.
726
     *
727
     * @param string $link
728
     *
729
     * @return Collection<Repository>
730
     */
731
    public function linkedRepositories(string $link): Collection
732
    {
733
        return DB::table('other')
734
            ->join('link', static function (JoinClause $join): void {
735
                $join
736
                    ->on('l_file', '=', 'o_file')
737
                    ->on('l_from', '=', 'o_id');
738
            })
739
            ->where('o_file', '=', $this->tree->id())
740
            ->where('o_type', '=', Repository::RECORD_TYPE)
741
            ->where('l_type', '=', $link)
742
            ->where('l_to', '=', $this->xref)
743
            ->select(['other.*'])
744
            ->get()
745
            ->map(Registry::repositoryFactory()->mapper($this->tree))
746
            ->filter(self::accessFilter());
747
    }
748
749
    /**
750
     * Find locations linked to this record.
751
     *
752
     * @param string $link
753
     *
754
     * @return Collection<Location>
755
     */
756
    public function linkedLocations(string $link): Collection
757
    {
758
        return DB::table('other')
759
            ->join('link', static function (JoinClause $join): void {
760
                $join
761
                    ->on('l_file', '=', 'o_file')
762
                    ->on('l_from', '=', 'o_id');
763
            })
764
            ->where('o_file', '=', $this->tree->id())
765
            ->where('o_type', '=', Location::RECORD_TYPE)
766
            ->where('l_type', '=', $link)
767
            ->where('l_to', '=', $this->xref)
768
            ->select(['other.*'])
769
            ->get()
770
            ->map(Registry::locationFactory()->mapper($this->tree))
771
            ->filter(self::accessFilter());
772
    }
773
774
    /**
775
     * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR).
776
     * This is used to display multiple events on the individual/family lists.
777
     * Multiple events can exist because of uncertainty in dates, dates in different
778
     * calendars, place-names in both latin and hebrew character sets, etc.
779
     * It also allows us to combine dates/places from different events in the summaries.
780
     *
781
     * @param string[] $events
782
     *
783
     * @return Date[]
784
     */
785
    public function getAllEventDates(array $events): array
786
    {
787
        $dates = [];
788
        foreach ($this->facts($events) as $event) {
789
            if ($event->date()->isOK()) {
790
                $dates[] = $event->date();
791
            }
792
        }
793
794
        return $dates;
795
    }
796
797
    /**
798
     * Get all the places for a particular type of event
799
     *
800
     * @param string[] $events
801
     *
802
     * @return Place[]
803
     */
804
    public function getAllEventPlaces(array $events): array
805
    {
806
        $places = [];
807
        foreach ($this->facts($events) as $event) {
808
            if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->gedcom(), $ged_places)) {
809
                foreach ($ged_places[1] as $ged_place) {
810
                    $places[] = new Place($ged_place, $this->tree);
811
                }
812
            }
813
        }
814
815
        return $places;
816
    }
817
818
    /**
819
     * The facts and events for this record.
820
     *
821
     * @param string[] $filter
822
     * @param bool     $sort
823
     * @param int|null $access_level
824
     * @param bool     $ignore_deleted
825
     *
826
     * @return Collection<Fact>
827
     */
828
    public function facts(
829
        array $filter = [],
830
        bool $sort = false,
831
        int $access_level = null,
832
        bool $ignore_deleted = false
833
    ): Collection {
834
        $access_level = $access_level ?? Auth::accessLevel($this->tree);
835
836
        $facts = new Collection();
837
        if ($this->canShow($access_level)) {
838
            foreach ($this->facts as $fact) {
839
                if (($filter === [] || in_array($fact->getTag(), $filter, true)) && $fact->canShow($access_level)) {
0 ignored issues
show
Deprecated Code introduced by
The function Fisharebest\Webtrees\Fact::getTag() has been deprecated: since 2.0.5. Will be removed in 2.1.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

839
                if (($filter === [] || in_array(/** @scrutinizer ignore-deprecated */ $fact->getTag(), $filter, true)) && $fact->canShow($access_level)) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
840
                    $facts->push($fact);
841
                }
842
            }
843
        }
844
845
        if ($sort) {
846
            $facts = Fact::sortFacts($facts);
847
        }
848
849
        if ($ignore_deleted) {
850
            $facts = $facts->filter(static function (Fact $fact): bool {
851
                return !$fact->isPendingDeletion();
852
            });
853
        }
854
855
        return new Collection($facts);
856
    }
857
858
    /**
859
     * Get the last-change timestamp for this record
860
     *
861
     * @return Carbon
862
     */
863
    public function lastChangeTimestamp(): Carbon
864
    {
865
        /** @var Fact|null $chan */
866
        $chan = $this->facts(['CHAN'])->first();
867
868
        if ($chan instanceof Fact) {
869
            // The record does have a CHAN event
870
            $d = $chan->date()->minimumDate();
871
872
            if (preg_match('/\n3 TIME (\d\d):(\d\d):(\d\d)/', $chan->gedcom(), $match)) {
873
                return Carbon::create($d->year(), $d->month(), $d->day(), (int) $match[1], (int) $match[2], (int) $match[3]);
874
            }
875
876
            if (preg_match('/\n3 TIME (\d\d):(\d\d)/', $chan->gedcom(), $match)) {
877
                return Carbon::create($d->year(), $d->month(), $d->day(), (int) $match[1], (int) $match[2]);
878
            }
879
880
            return Carbon::create($d->year(), $d->month(), $d->day());
881
        }
882
883
        // The record does not have a CHAN event
884
        return Carbon::createFromTimestamp(0);
885
    }
886
887
    /**
888
     * Get the last-change user for this record
889
     *
890
     * @return string
891
     */
892
    public function lastChangeUser(): string
893
    {
894
        $chan = $this->facts(['CHAN'])->first();
895
896
        if ($chan === null) {
897
            return I18N::translate('Unknown');
898
        }
899
900
        $chan_user = $chan->attribute('_WT_USER');
901
        if ($chan_user === '') {
902
            return I18N::translate('Unknown');
903
        }
904
905
        return $chan_user;
906
    }
907
908
    /**
909
     * Add a new fact to this record
910
     *
911
     * @param string $gedcom
912
     * @param bool   $update_chan
913
     *
914
     * @return void
915
     */
916
    public function createFact(string $gedcom, bool $update_chan): void
917
    {
918
        $this->updateFact('', $gedcom, $update_chan);
919
    }
920
921
    /**
922
     * Delete a fact from this record
923
     *
924
     * @param string $fact_id
925
     * @param bool   $update_chan
926
     *
927
     * @return void
928
     */
929
    public function deleteFact(string $fact_id, bool $update_chan): void
930
    {
931
        $this->updateFact($fact_id, '', $update_chan);
932
    }
933
934
    /**
935
     * Replace a fact with a new gedcom data.
936
     *
937
     * @param string $fact_id
938
     * @param string $gedcom
939
     * @param bool   $update_chan
940
     *
941
     * @return void
942
     * @throws Exception
943
     */
944
    public function updateFact(string $fact_id, string $gedcom, bool $update_chan): void
945
    {
946
        // Not all record types allow a CHAN event.
947
        $update_chan = $update_chan && in_array(static::RECORD_TYPE, Gedcom::RECORDS_WITH_CHAN, true);
948
949
        // MSDOS line endings will break things in horrible ways
950
        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
951
        $gedcom = trim($gedcom);
952
953
        if ($this->pending === '') {
954
            throw new Exception('Cannot edit a deleted record');
955
        }
956
        if ($gedcom !== '' && !preg_match('/^1 ' . Gedcom::REGEX_TAG . '/', $gedcom)) {
957
            throw new Exception('Invalid GEDCOM data passed to GedcomRecord::updateFact(' . $gedcom . ')');
958
        }
959
960
        if ($this->pending) {
961
            $old_gedcom = $this->pending;
962
        } else {
963
            $old_gedcom = $this->gedcom;
964
        }
965
966
        // First line of record may contain data - e.g. NOTE records.
967
        [$new_gedcom] = explode("\n", $old_gedcom, 2);
968
969
        // Replacing (or deleting) an existing fact
970
        foreach ($this->facts([], false, Auth::PRIV_HIDE, true) as $fact) {
971
            if ($fact->id() === $fact_id) {
972
                if ($gedcom !== '') {
973
                    $new_gedcom .= "\n" . $gedcom;
974
                }
975
                $fact_id = 'NOT A VALID FACT ID'; // Only replace/delete one copy of a duplicate fact
976
            } elseif ($fact->getTag() !== 'CHAN' || !$update_chan) {
977
                $new_gedcom .= "\n" . $fact->gedcom();
978
            }
979
        }
980
981
        // Adding a new fact
982
        if ($fact_id === '') {
983
            $new_gedcom .= "\n" . $gedcom;
984
        }
985
986
        if ($update_chan && !str_contains($new_gedcom, "\n1 CHAN")) {
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

986
        if ($update_chan && !/** @scrutinizer ignore-deprecated */ str_contains($new_gedcom, "\n1 CHAN")) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
987
            $today = strtoupper(date('d M Y'));
988
            $now   = date('H:i:s');
989
            $new_gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
990
        }
991
992
        if ($new_gedcom !== $old_gedcom) {
993
            // Save the changes
994
            DB::table('change')->insert([
995
                'gedcom_id'  => $this->tree->id(),
996
                'xref'       => $this->xref,
997
                'old_gedcom' => $old_gedcom,
998
                'new_gedcom' => $new_gedcom,
999
                'user_id'    => Auth::id(),
1000
            ]);
1001
1002
            $this->pending = $new_gedcom;
1003
1004
            if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
1005
                app(PendingChangesService::class)->acceptRecord($this);
1006
                $this->gedcom  = $new_gedcom;
1007
                $this->pending = null;
1008
            }
1009
        }
1010
        $this->parseFacts();
1011
    }
1012
1013
    /**
1014
     * Update this record
1015
     *
1016
     * @param string $gedcom
1017
     * @param bool   $update_chan
1018
     *
1019
     * @return void
1020
     */
1021
    public function updateRecord(string $gedcom, bool $update_chan): void
1022
    {
1023
        // Not all record types allow a CHAN event.
1024
        $update_chan = $update_chan && in_array(static::RECORD_TYPE, Gedcom::RECORDS_WITH_CHAN, true);
1025
1026
        // MSDOS line endings will break things in horrible ways
1027
        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1028
        $gedcom = trim($gedcom);
1029
1030
        // Update the CHAN record
1031
        if ($update_chan) {
1032
            $gedcom = preg_replace('/\n1 CHAN(\n[2-9].*)*/', '', $gedcom);
1033
            $today = strtoupper(date('d M Y'));
1034
            $now   = date('H:i:s');
1035
            $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
1036
        }
1037
1038
        // Create a pending change
1039
        DB::table('change')->insert([
1040
            'gedcom_id'  => $this->tree->id(),
1041
            'xref'       => $this->xref,
1042
            'old_gedcom' => $this->gedcom(),
1043
            'new_gedcom' => $gedcom,
1044
            'user_id'    => Auth::id(),
1045
        ]);
1046
1047
        // Clear the cache
1048
        $this->pending = $gedcom;
1049
1050
        // Accept this pending change
1051
        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
1052
            app(PendingChangesService::class)->acceptRecord($this);
1053
            $this->gedcom  = $gedcom;
1054
            $this->pending = null;
1055
        }
1056
1057
        $this->parseFacts();
1058
1059
        Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1060
    }
1061
1062
    /**
1063
     * Delete this record
1064
     *
1065
     * @return void
1066
     */
1067
    public function deleteRecord(): void
1068
    {
1069
        // Create a pending change
1070
        if (!$this->isPendingDeletion()) {
1071
            DB::table('change')->insert([
1072
                'gedcom_id'  => $this->tree->id(),
1073
                'xref'       => $this->xref,
1074
                'old_gedcom' => $this->gedcom(),
1075
                'new_gedcom' => '',
1076
                'user_id'    => Auth::id(),
1077
            ]);
1078
        }
1079
1080
        // Auto-accept this pending change
1081
        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
1082
            app(PendingChangesService::class)->acceptRecord($this);
1083
        }
1084
1085
        Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1086
    }
1087
1088
    /**
1089
     * Remove all links from this record to $xref
1090
     *
1091
     * @param string $xref
1092
     * @param bool   $update_chan
1093
     *
1094
     * @return void
1095
     */
1096
    public function removeLinks(string $xref, bool $update_chan): void
1097
    {
1098
        $value = '@' . $xref . '@';
1099
1100
        foreach ($this->facts() as $fact) {
1101
            if ($fact->value() === $value) {
1102
                $this->deleteFact($fact->id(), $update_chan);
1103
            } elseif (preg_match_all('/\n(\d) ' . Gedcom::REGEX_TAG . ' ' . $value . '/', $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1104
                $gedcom = $fact->gedcom();
1105
                foreach ($matches as $match) {
1106
                    $next_level  = $match[1] + 1;
1107
                    $next_levels = '[' . $next_level . '-9]';
1108
                    $gedcom      = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom);
1109
                }
1110
                $this->updateFact($fact->id(), $gedcom, $update_chan);
1111
            }
1112
        }
1113
    }
1114
1115
    /**
1116
     * Fetch XREFs of all records linked to a record - when deleting an object, we must
1117
     * also delete all links to it.
1118
     *
1119
     * @return GedcomRecord[]
1120
     */
1121
    public function linkingRecords(): array
1122
    {
1123
        $like = addcslashes($this->xref(), '\\%_');
1124
1125
        $union = DB::table('change')
1126
            ->where('gedcom_id', '=', $this->tree()->id())
1127
            ->where('new_gedcom', 'LIKE', '%@' . $like . '@%')
1128
            ->where('new_gedcom', 'NOT LIKE', '0 @' . $like . '@%')
1129
            ->whereIn('change_id', function (Builder $query): void {
1130
                $query->select(new Expression('MAX(change_id)'))
1131
                    ->from('change')
1132
                    ->where('gedcom_id', '=', $this->tree->id())
1133
                    ->where('status', '=', 'pending')
1134
                    ->groupBy(['xref']);
1135
            })
1136
            ->select(['xref']);
1137
1138
        $xrefs = DB::table('link')
1139
            ->where('l_file', '=', $this->tree()->id())
1140
            ->where('l_to', '=', $this->xref())
1141
            ->select(['l_from'])
1142
            ->union($union)
1143
            ->pluck('l_from');
1144
1145
        return $xrefs->map(function (string $xref): GedcomRecord {
1146
            $record = Registry::gedcomRecordFactory()->make($xref, $this->tree);
1147
            assert($record instanceof GedcomRecord);
1148
1149
            return $record;
1150
        })->all();
1151
    }
1152
1153
    /**
1154
     * Each object type may have its own special rules, and re-implement this function.
1155
     *
1156
     * @param int $access_level
1157
     *
1158
     * @return bool
1159
     */
1160
    protected function canShowByType(int $access_level): bool
1161
    {
1162
        $fact_privacy = $this->tree->getFactPrivacy();
1163
1164
        if (isset($fact_privacy[static::RECORD_TYPE])) {
1165
            // Restriction found
1166
            return $fact_privacy[static::RECORD_TYPE] >= $access_level;
1167
        }
1168
1169
        // No restriction found - must be public:
1170
        return true;
1171
    }
1172
1173
    /**
1174
     * Generate a private version of this record
1175
     *
1176
     * @param int $access_level
1177
     *
1178
     * @return string
1179
     */
1180
    protected function createPrivateGedcomRecord(int $access_level): string
1181
    {
1182
        return '0 @' . $this->xref . '@ ' . static::RECORD_TYPE . "\n1 NOTE " . I18N::translate('Private');
1183
    }
1184
1185
    /**
1186
     * Convert a name record into sortable and full/display versions. This default
1187
     * should be OK for simple record types. INDI/FAM records will need to redefine it.
1188
     *
1189
     * @param string $type
1190
     * @param string $value
1191
     * @param string $gedcom
1192
     *
1193
     * @return void
1194
     */
1195
    protected function addName(string $type, string $value, string $gedcom): void
1196
    {
1197
        $this->getAllNames[] = [
1198
            'type'   => $type,
1199
            'sort'   => preg_replace_callback('/([0-9]+)/', static function (array $matches): string {
1200
                return str_pad($matches[0], 10, '0', STR_PAD_LEFT);
1201
            }, $value),
1202
            'full'   => '<span dir="auto">' . e($value) . '</span>',
1203
            // This is used for display
1204
            'fullNN' => $value,
1205
            // This goes into the database
1206
        ];
1207
    }
1208
1209
    /**
1210
     * Get all the names of a record, including ROMN, FONE and _HEB alternatives.
1211
     * Records without a name (e.g. FAM) will need to redefine this function.
1212
     * Parameters: the level 1 fact containing the name.
1213
     * Return value: an array of name structures, each containing
1214
     * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc.
1215
     * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown'
1216
     * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John'
1217
     *
1218
     * @param int              $level
1219
     * @param string           $fact_type
1220
     * @param Collection<Fact> $facts
1221
     *
1222
     * @return void
1223
     */
1224
    protected function extractNamesFromFacts(int $level, string $fact_type, Collection $facts): void
1225
    {
1226
        $sublevel    = $level + 1;
1227
        $subsublevel = $sublevel + 1;
1228
        foreach ($facts as $fact) {
1229
            if (preg_match_all("/^{$level} ({$fact_type}) (.+)((\n[{$sublevel}-9].+)*)/m", $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1230
                foreach ($matches as $match) {
1231
                    // Treat 1 NAME / 2 TYPE married the same as _MARNM
1232
                    if ($match[1] === 'NAME' && str_contains($match[3], "\n2 TYPE married")) {
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1232
                    if ($match[1] === 'NAME' && /** @scrutinizer ignore-deprecated */ str_contains($match[3], "\n2 TYPE married")) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1233
                        $this->addName('_MARNM', $match[2], $fact->gedcom());
1234
                    } else {
1235
                        $this->addName($match[1], $match[2], $fact->gedcom());
1236
                    }
1237
                    if ($match[3] && preg_match_all("/^{$sublevel} (ROMN|FONE|_\w+) (.+)((\n[{$subsublevel}-9].+)*)/m", $match[3], $submatches, PREG_SET_ORDER)) {
1238
                        foreach ($submatches as $submatch) {
1239
                            $this->addName($submatch[1], $submatch[2], $match[3]);
1240
                        }
1241
                    }
1242
                }
1243
            }
1244
        }
1245
    }
1246
1247
    /**
1248
     * Split the record into facts
1249
     *
1250
     * @return void
1251
     */
1252
    private function parseFacts(): void
1253
    {
1254
        // Split the record into facts
1255
        if ($this->gedcom) {
1256
            $gedcom_facts = preg_split('/\n(?=1)/', $this->gedcom);
1257
            array_shift($gedcom_facts);
0 ignored issues
show
Bug introduced by
It seems like $gedcom_facts can also be of type false; however, parameter $array of array_shift() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1257
            array_shift(/** @scrutinizer ignore-type */ $gedcom_facts);
Loading history...
1258
        } else {
1259
            $gedcom_facts = [];
1260
        }
1261
        if ($this->pending) {
1262
            $pending_facts = preg_split('/\n(?=1)/', $this->pending);
1263
            array_shift($pending_facts);
1264
        } else {
1265
            $pending_facts = [];
1266
        }
1267
1268
        $this->facts = [];
1269
1270
        foreach ($gedcom_facts as $gedcom_fact) {
1271
            $fact = new Fact($gedcom_fact, $this, md5($gedcom_fact));
1272
            if ($this->pending !== null && !in_array($gedcom_fact, $pending_facts, true)) {
1273
                $fact->setPendingDeletion();
1274
            }
1275
            $this->facts[] = $fact;
1276
        }
1277
        foreach ($pending_facts as $pending_fact) {
1278
            if (!in_array($pending_fact, $gedcom_facts, true)) {
1279
                $fact = new Fact($pending_fact, $this, md5($pending_fact));
1280
                $fact->setPendingAddition();
1281
                $this->facts[] = $fact;
1282
            }
1283
        }
1284
    }
1285
1286
    /**
1287
     * Work out whether this record can be shown to a user with a given access level
1288
     *
1289
     * @param int $access_level
1290
     *
1291
     * @return bool
1292
     */
1293
    private function canShowRecord(int $access_level): bool
1294
    {
1295
        // This setting would better be called "$ENABLE_PRIVACY"
1296
        if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) {
1297
            return true;
1298
        }
1299
1300
        // We should always be able to see our own record (unless an admin is applying download restrictions)
1301
        if ($this->xref() === $this->tree->getUserPreference(Auth::user(), User::PREF_TREE_ACCOUNT_XREF) && $access_level === Auth::accessLevel($this->tree)) {
1302
            return true;
1303
        }
1304
1305
        // Does this record have a RESN?
1306
        if (str_contains($this->gedcom, "\n1 RESN confidential")) {
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1306
        if (/** @scrutinizer ignore-deprecated */ str_contains($this->gedcom, "\n1 RESN confidential")) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1307
            return Auth::PRIV_NONE >= $access_level;
1308
        }
1309
        if (str_contains($this->gedcom, "\n1 RESN privacy")) {
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1309
        if (/** @scrutinizer ignore-deprecated */ str_contains($this->gedcom, "\n1 RESN privacy")) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1310
            return Auth::PRIV_USER >= $access_level;
1311
        }
1312
        if (str_contains($this->gedcom, "\n1 RESN none")) {
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1312
        if (/** @scrutinizer ignore-deprecated */ str_contains($this->gedcom, "\n1 RESN none")) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1313
            return true;
1314
        }
1315
1316
        // Does this record have a default RESN?
1317
        $individual_privacy = $this->tree->getIndividualPrivacy();
1318
        if (isset($individual_privacy[$this->xref()])) {
1319
            return $individual_privacy[$this->xref()] >= $access_level;
1320
        }
1321
1322
        // Privacy rules do not apply to admins
1323
        if (Auth::PRIV_NONE >= $access_level) {
1324
            return true;
1325
        }
1326
1327
        // Different types of record have different privacy rules
1328
        return $this->canShowByType($access_level);
1329
    }
1330
1331
    /**
1332
     * Lock the database row, to prevent concurrent edits.
1333
     */
1334
    public function lock(): void
1335
    {
1336
        DB::table('other')
1337
            ->where('o_file', '=', $this->tree->id())
1338
            ->where('o_id', '=', $this->xref())
1339
            ->lockForUpdate()
1340
            ->get();
1341
    }
1342
}
1343