xref: /webtrees/app/Tree.php (revision 1d1f373cc23093682b1736881f66a527f62ccc46)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees;
21
22use Closure;
23use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24use Fisharebest\Webtrees\Contracts\UserInterface;
25use Fisharebest\Webtrees\Functions\FunctionsExport;
26use Fisharebest\Webtrees\Functions\FunctionsImport;
27use Fisharebest\Webtrees\Services\TreeService;
28use Illuminate\Database\Capsule\Manager as DB;
29use Illuminate\Database\Query\Expression;
30use Illuminate\Support\Collection;
31use Illuminate\Support\Str;
32use InvalidArgumentException;
33use League\Flysystem\Filesystem;
34use League\Flysystem\FilesystemInterface;
35use Psr\Http\Message\StreamInterface;
36use stdClass;
37
38/**
39 * Provide an interface to the wt_gedcom table.
40 */
41class Tree
42{
43    private const RESN_PRIVACY = [
44        'none'         => Auth::PRIV_PRIVATE,
45        'privacy'      => Auth::PRIV_USER,
46        'confidential' => Auth::PRIV_NONE,
47        'hidden'       => Auth::PRIV_HIDE,
48    ];
49
50    /** @var int The tree's ID number */
51    private $id;
52
53    /** @var string The tree's name */
54    private $name;
55
56    /** @var string The tree's title */
57    private $title;
58
59    /** @var int[] Default access rules for facts in this tree */
60    private $fact_privacy;
61
62    /** @var int[] Default access rules for individuals in this tree */
63    private $individual_privacy;
64
65    /** @var integer[][] Default access rules for individual facts in this tree */
66    private $individual_fact_privacy;
67
68    /** @var string[] Cached copy of the wt_gedcom_setting table. */
69    private $preferences = [];
70
71    /** @var string[][] Cached copy of the wt_user_gedcom_setting table. */
72    private $user_preferences = [];
73
74    /**
75     * Create a tree object.
76     *
77     * @param int    $id
78     * @param string $name
79     * @param string $title
80     */
81    public function __construct(int $id, string $name, string $title)
82    {
83        $this->id                      = $id;
84        $this->name                    = $name;
85        $this->title                   = $title;
86        $this->fact_privacy            = [];
87        $this->individual_privacy      = [];
88        $this->individual_fact_privacy = [];
89
90        // Load the privacy settings for this tree
91        $rows = DB::table('default_resn')
92            ->where('gedcom_id', '=', $this->id)
93            ->get();
94
95        foreach ($rows as $row) {
96            // Convert GEDCOM privacy restriction to a webtrees access level.
97            $row->resn = self::RESN_PRIVACY[$row->resn];
98
99            if ($row->xref !== null) {
100                if ($row->tag_type !== null) {
101                    $this->individual_fact_privacy[$row->xref][$row->tag_type] = $row->resn;
102                } else {
103                    $this->individual_privacy[$row->xref] = $row->resn;
104                }
105            } else {
106                $this->fact_privacy[$row->tag_type] = $row->resn;
107            }
108        }
109    }
110
111    /**
112     * A closure which will create a record from a database row.
113     *
114     * @return Closure
115     */
116    public static function rowMapper(): Closure
117    {
118        return static function (stdClass $row): Tree {
119            return new Tree((int) $row->tree_id, $row->tree_name, $row->tree_title);
120        };
121    }
122
123    /**
124     * Find the tree with a specific ID.
125     *
126     * @param int $tree_id
127     *
128     * @return Tree
129     */
130    public static function findById(int $tree_id): Tree
131    {
132        return self::getAll()[$tree_id];
133    }
134
135    /**
136     * Fetch all the trees that we have permission to access.
137     *
138     * @return Tree[]
139     * @deprecated
140     */
141    public static function getAll(): array
142    {
143        return (new TreeService())->all()->all();
144    }
145
146    /**
147     * All the trees that we have permission to access.
148     *
149     * @return Collection
150     * @deprecated
151     */
152    public static function all(): Collection
153    {
154        return (new TreeService())->all();
155    }
156
157    /**
158     * Create arguments to select_edit_control()
159     * Note - these will be escaped later
160     *
161     * @return string[]
162     */
163    public static function getIdList(): array
164    {
165        $list = [];
166        foreach (self::getAll() as $tree) {
167            $list[$tree->id] = $tree->title;
168        }
169
170        return $list;
171    }
172
173    /**
174     * Create arguments to select_edit_control()
175     * Note - these will be escaped later
176     *
177     * @return string[]
178     */
179    public static function getNameList(): array
180    {
181        $list = [];
182        foreach (self::getAll() as $tree) {
183            $list[$tree->name] = $tree->title;
184        }
185
186        return $list;
187    }
188
189    /**
190     * Create a new tree
191     *
192     * @param string $tree_name
193     * @param string $tree_title
194     *
195     * @return Tree
196     * @deprecated
197     */
198    public static function create(string $tree_name, string $tree_title): Tree
199    {
200        return (new TreeService())->create($tree_name, $tree_title);
201    }
202
203    /**
204     * Find the tree with a specific name.
205     *
206     * @param string $name
207     *
208     * @return Tree|null
209     * @deprecated
210     */
211    public static function findByName($name): ?Tree
212    {
213        foreach (self::getAll() as $tree) {
214            if ($tree->name === $name) {
215                return $tree;
216            }
217        }
218
219        return null;
220    }
221
222    /**
223     * Set the tree’s configuration settings.
224     *
225     * @param string $setting_name
226     * @param string $setting_value
227     *
228     * @return $this
229     */
230    public function setPreference(string $setting_name, string $setting_value): Tree
231    {
232        if ($setting_value !== $this->getPreference($setting_name)) {
233            DB::table('gedcom_setting')->updateOrInsert([
234                'gedcom_id'    => $this->id,
235                'setting_name' => $setting_name,
236            ], [
237                'setting_value' => $setting_value,
238            ]);
239
240            $this->preferences[$setting_name] = $setting_value;
241
242            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this);
243        }
244
245        return $this;
246    }
247
248    /**
249     * Get the tree’s configuration settings.
250     *
251     * @param string $setting_name
252     * @param string $default
253     *
254     * @return string
255     */
256    public function getPreference(string $setting_name, string $default = ''): string
257    {
258        if ($this->preferences === []) {
259            $this->preferences = DB::table('gedcom_setting')
260                ->where('gedcom_id', '=', $this->id)
261                ->pluck('setting_value', 'setting_name')
262                ->all();
263        }
264
265        return $this->preferences[$setting_name] ?? $default;
266    }
267
268    /**
269     * The name of this tree
270     *
271     * @return string
272     */
273    public function name(): string
274    {
275        return $this->name;
276    }
277
278    /**
279     * The title of this tree
280     *
281     * @return string
282     */
283    public function title(): string
284    {
285        return $this->title;
286    }
287
288    /**
289     * The fact-level privacy for this tree.
290     *
291     * @return int[]
292     */
293    public function getFactPrivacy(): array
294    {
295        return $this->fact_privacy;
296    }
297
298    /**
299     * The individual-level privacy for this tree.
300     *
301     * @return int[]
302     */
303    public function getIndividualPrivacy(): array
304    {
305        return $this->individual_privacy;
306    }
307
308    /**
309     * The individual-fact-level privacy for this tree.
310     *
311     * @return int[][]
312     */
313    public function getIndividualFactPrivacy(): array
314    {
315        return $this->individual_fact_privacy;
316    }
317
318    /**
319     * Set the tree’s user-configuration settings.
320     *
321     * @param UserInterface $user
322     * @param string        $setting_name
323     * @param string        $setting_value
324     *
325     * @return $this
326     */
327    public function setUserPreference(UserInterface $user, string $setting_name, string $setting_value): Tree
328    {
329        if ($this->getUserPreference($user, $setting_name) !== $setting_value) {
330            // Update the database
331            DB::table('user_gedcom_setting')->updateOrInsert([
332                'gedcom_id'    => $this->id(),
333                'user_id'      => $user->id(),
334                'setting_name' => $setting_name,
335            ], [
336                'setting_value' => $setting_value,
337            ]);
338
339            // Update the cache
340            $this->user_preferences[$user->id()][$setting_name] = $setting_value;
341            // Audit log of changes
342            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->userName() . '"', $this);
343        }
344
345        return $this;
346    }
347
348    /**
349     * Get the tree’s user-configuration settings.
350     *
351     * @param UserInterface $user
352     * @param string        $setting_name
353     * @param string        $default
354     *
355     * @return string
356     */
357    public function getUserPreference(UserInterface $user, string $setting_name, string $default = ''): string
358    {
359        // There are lots of settings, and we need to fetch lots of them on every page
360        // so it is quicker to fetch them all in one go.
361        if (!array_key_exists($user->id(), $this->user_preferences)) {
362            $this->user_preferences[$user->id()] = DB::table('user_gedcom_setting')
363                ->where('user_id', '=', $user->id())
364                ->where('gedcom_id', '=', $this->id)
365                ->pluck('setting_value', 'setting_name')
366                ->all();
367        }
368
369        return $this->user_preferences[$user->id()][$setting_name] ?? $default;
370    }
371
372    /**
373     * The ID of this tree
374     *
375     * @return int
376     */
377    public function id(): int
378    {
379        return $this->id;
380    }
381
382    /**
383     * Can a user accept changes for this tree?
384     *
385     * @param UserInterface $user
386     *
387     * @return bool
388     */
389    public function canAcceptChanges(UserInterface $user): bool
390    {
391        return Auth::isModerator($this, $user);
392    }
393
394    /**
395     * Are there any pending edits for this tree, than need reviewing by a moderator.
396     *
397     * @return bool
398     */
399    public function hasPendingEdit(): bool
400    {
401        return DB::table('change')
402            ->where('gedcom_id', '=', $this->id)
403            ->where('status', '=', 'pending')
404            ->exists();
405    }
406
407    /**
408     * Delete everything relating to a tree
409     *
410     * @return void
411     */
412    public function delete(): void
413    {
414        // If this is the default tree, then unset it
415        if (Site::getPreference('DEFAULT_GEDCOM') === $this->name) {
416            Site::setPreference('DEFAULT_GEDCOM', '');
417        }
418
419        $this->deleteGenealogyData(false);
420
421        DB::table('block_setting')
422            ->join('block', 'block.block_id', '=', 'block_setting.block_id')
423            ->where('gedcom_id', '=', $this->id)
424            ->delete();
425        DB::table('block')->where('gedcom_id', '=', $this->id)->delete();
426        DB::table('user_gedcom_setting')->where('gedcom_id', '=', $this->id)->delete();
427        DB::table('gedcom_setting')->where('gedcom_id', '=', $this->id)->delete();
428        DB::table('module_privacy')->where('gedcom_id', '=', $this->id)->delete();
429        DB::table('hit_counter')->where('gedcom_id', '=', $this->id)->delete();
430        DB::table('default_resn')->where('gedcom_id', '=', $this->id)->delete();
431        DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete();
432        DB::table('log')->where('gedcom_id', '=', $this->id)->delete();
433        DB::table('gedcom')->where('gedcom_id', '=', $this->id)->delete();
434    }
435
436    /**
437     * Delete all the genealogy data from a tree - in preparation for importing
438     * new data. Optionally retain the media data, for when the user has been
439     * editing their data offline using an application which deletes (or does not
440     * support) media data.
441     *
442     * @param bool $keep_media
443     *
444     * @return void
445     */
446    public function deleteGenealogyData(bool $keep_media): void
447    {
448        DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete();
449        DB::table('individuals')->where('i_file', '=', $this->id)->delete();
450        DB::table('families')->where('f_file', '=', $this->id)->delete();
451        DB::table('sources')->where('s_file', '=', $this->id)->delete();
452        DB::table('other')->where('o_file', '=', $this->id)->delete();
453        DB::table('places')->where('p_file', '=', $this->id)->delete();
454        DB::table('placelinks')->where('pl_file', '=', $this->id)->delete();
455        DB::table('name')->where('n_file', '=', $this->id)->delete();
456        DB::table('dates')->where('d_file', '=', $this->id)->delete();
457        DB::table('change')->where('gedcom_id', '=', $this->id)->delete();
458
459        if ($keep_media) {
460            DB::table('link')->where('l_file', '=', $this->id)
461                ->where('l_type', '<>', 'OBJE')
462                ->delete();
463        } else {
464            DB::table('link')->where('l_file', '=', $this->id)->delete();
465            DB::table('media_file')->where('m_file', '=', $this->id)->delete();
466            DB::table('media')->where('m_file', '=', $this->id)->delete();
467        }
468    }
469
470    /**
471     * Export the tree to a GEDCOM file
472     *
473     * @param resource $stream
474     *
475     * @return void
476     */
477    public function exportGedcom($stream): void
478    {
479        $buffer = FunctionsExport::reformatRecord(FunctionsExport::gedcomHeader($this, 'UTF-8'));
480
481        $union_families = DB::table('families')
482            ->where('f_file', '=', $this->id)
483            ->select(['f_gedcom AS gedcom', 'f_id AS xref', new Expression('LENGTH(f_id) AS len'), new Expression('2 AS n')]);
484
485        $union_sources = DB::table('sources')
486            ->where('s_file', '=', $this->id)
487            ->select(['s_gedcom AS gedcom', 's_id AS xref', new Expression('LENGTH(s_id) AS len'), new Expression('3 AS n')]);
488
489        $union_other = DB::table('other')
490            ->where('o_file', '=', $this->id)
491            ->whereNotIn('o_type', ['HEAD', 'TRLR'])
492            ->select(['o_gedcom AS gedcom', 'o_id AS xref', new Expression('LENGTH(o_id) AS len'), new Expression('4 AS n')]);
493
494        $union_media = DB::table('media')
495            ->where('m_file', '=', $this->id)
496            ->select(['m_gedcom AS gedcom', 'm_id AS xref', new Expression('LENGTH(m_id) AS len'), new Expression('5 AS n')]);
497
498        DB::table('individuals')
499            ->where('i_file', '=', $this->id)
500            ->select(['i_gedcom AS gedcom', 'i_id AS xref', new Expression('LENGTH(i_id) AS len'), new Expression('1 AS n')])
501            ->union($union_families)
502            ->union($union_sources)
503            ->union($union_other)
504            ->union($union_media)
505            ->orderBy('n')
506            ->orderBy('len')
507            ->orderBy('xref')
508            ->chunk(1000, static function (Collection $rows) use ($stream, &$buffer): void {
509                foreach ($rows as $row) {
510                    $buffer .= FunctionsExport::reformatRecord($row->gedcom);
511                    if (strlen($buffer) > 65535) {
512                        fwrite($stream, $buffer);
513                        $buffer = '';
514                    }
515                }
516            });
517
518        fwrite($stream, $buffer . '0 TRLR' . Gedcom::EOL);
519    }
520
521    /**
522     * Import data from a gedcom file into this tree.
523     *
524     * @param StreamInterface $stream   The GEDCOM file.
525     * @param string          $filename The preferred filename, for export/download.
526     *
527     * @return void
528     */
529    public function importGedcomFile(StreamInterface $stream, string $filename): void
530    {
531        // Read the file in blocks of roughly 64K. Ensure that each block
532        // contains complete gedcom records. This will ensure we don’t split
533        // multi-byte characters, as well as simplifying the code to import
534        // each block.
535
536        $file_data = '';
537
538        $this->deleteGenealogyData((bool) $this->getPreference('keep_media'));
539        $this->setPreference('gedcom_filename', $filename);
540        $this->setPreference('imported', '0');
541
542        while (!$stream->eof()) {
543            $file_data .= $stream->read(65536);
544            // There is no strrpos() function that searches for substrings :-(
545            for ($pos = strlen($file_data) - 1; $pos > 0; --$pos) {
546                if ($file_data[$pos] === '0' && ($file_data[$pos - 1] === "\n" || $file_data[$pos - 1] === "\r")) {
547                    // We’ve found the last record boundary in this chunk of data
548                    break;
549                }
550            }
551            if ($pos) {
552                DB::table('gedcom_chunk')->insert([
553                    'gedcom_id'  => $this->id,
554                    'chunk_data' => substr($file_data, 0, $pos),
555                ]);
556
557                $file_data = substr($file_data, $pos);
558            }
559        }
560        DB::table('gedcom_chunk')->insert([
561            'gedcom_id'  => $this->id,
562            'chunk_data' => $file_data,
563        ]);
564
565        $stream->close();
566    }
567
568    /**
569     * Create a new record from GEDCOM data.
570     *
571     * @param string $gedcom
572     *
573     * @return GedcomRecord|Individual|Family|Note|Source|Repository|Media
574     * @throws InvalidArgumentException
575     */
576    public function createRecord(string $gedcom): GedcomRecord
577    {
578        if (!Str::startsWith($gedcom, '0 @@ ')) {
579            throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@');
580        }
581
582        $xref   = $this->getNewXref();
583        $gedcom = '0 @' . $xref . '@ ' . Str::after($gedcom, '0 @@ ');
584
585        // Create a change record
586        $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName();
587
588        // Create a pending change
589        DB::table('change')->insert([
590            'gedcom_id'  => $this->id,
591            'xref'       => $xref,
592            'old_gedcom' => '',
593            'new_gedcom' => $gedcom,
594            'user_id'    => Auth::id(),
595        ]);
596
597        // Accept this pending change
598        if (Auth::user()->getPreference('auto_accept')) {
599            FunctionsImport::acceptAllChanges($xref, $this);
600
601            return new GedcomRecord($xref, $gedcom, null, $this);
602        }
603
604        return GedcomRecord::getInstance($xref, $this, $gedcom);
605    }
606
607    /**
608     * Generate a new XREF, unique across all family trees
609     *
610     * @return string
611     */
612    public function getNewXref(): string
613    {
614        // Lock the row, so that only one new XREF may be generated at a time.
615        DB::table('site_setting')
616            ->where('setting_name', '=', 'next_xref')
617            ->lockForUpdate()
618            ->get();
619
620        $prefix = 'X';
621
622        $increment = 1.0;
623        do {
624            $num = (int) Site::getPreference('next_xref') + (int) $increment;
625
626            // This exponential increment allows us to scan over large blocks of
627            // existing data in a reasonable time.
628            $increment *= 1.01;
629
630            $xref = $prefix . $num;
631
632            // Records may already exist with this sequence number.
633            $already_used =
634                DB::table('individuals')->where('i_id', '=', $xref)->exists() ||
635                DB::table('families')->where('f_id', '=', $xref)->exists() ||
636                DB::table('sources')->where('s_id', '=', $xref)->exists() ||
637                DB::table('media')->where('m_id', '=', $xref)->exists() ||
638                DB::table('other')->where('o_id', '=', $xref)->exists() ||
639                DB::table('change')->where('xref', '=', $xref)->exists();
640        } while ($already_used);
641
642        Site::setPreference('next_xref', (string) $num);
643
644        return $xref;
645    }
646
647    /**
648     * Create a new family from GEDCOM data.
649     *
650     * @param string $gedcom
651     *
652     * @return Family
653     * @throws InvalidArgumentException
654     */
655    public function createFamily(string $gedcom): GedcomRecord
656    {
657        if (!Str::startsWith($gedcom, '0 @@ FAM')) {
658            throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM');
659        }
660
661        $xref   = $this->getNewXref();
662        $gedcom = '0 @' . $xref . '@ FAM' . Str::after($gedcom, '0 @@ FAM');
663
664        // Create a change record
665        $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName();
666
667        // Create a pending change
668        DB::table('change')->insert([
669            'gedcom_id'  => $this->id,
670            'xref'       => $xref,
671            'old_gedcom' => '',
672            'new_gedcom' => $gedcom,
673            'user_id'    => Auth::id(),
674        ]);
675
676        // Accept this pending change
677        if (Auth::user()->getPreference('auto_accept')) {
678            FunctionsImport::acceptAllChanges($xref, $this);
679
680            return new Family($xref, $gedcom, null, $this);
681        }
682
683        return new Family($xref, '', $gedcom, $this);
684    }
685
686    /**
687     * Create a new individual from GEDCOM data.
688     *
689     * @param string $gedcom
690     *
691     * @return Individual
692     * @throws InvalidArgumentException
693     */
694    public function createIndividual(string $gedcom): GedcomRecord
695    {
696        if (!Str::startsWith($gedcom, '0 @@ INDI')) {
697            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI');
698        }
699
700        $xref   = $this->getNewXref();
701        $gedcom = '0 @' . $xref . '@ INDI' . Str::after($gedcom, '0 @@ INDI');
702
703        // Create a change record
704        $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName();
705
706        // Create a pending change
707        DB::table('change')->insert([
708            'gedcom_id'  => $this->id,
709            'xref'       => $xref,
710            'old_gedcom' => '',
711            'new_gedcom' => $gedcom,
712            'user_id'    => Auth::id(),
713        ]);
714
715        // Accept this pending change
716        if (Auth::user()->getPreference('auto_accept')) {
717            FunctionsImport::acceptAllChanges($xref, $this);
718
719            return new Individual($xref, $gedcom, null, $this);
720        }
721
722        return new Individual($xref, '', $gedcom, $this);
723    }
724
725    /**
726     * Create a new media object from GEDCOM data.
727     *
728     * @param string $gedcom
729     *
730     * @return Media
731     * @throws InvalidArgumentException
732     */
733    public function createMediaObject(string $gedcom): Media
734    {
735        if (!Str::startsWith($gedcom, '0 @@ OBJE')) {
736            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE');
737        }
738
739        $xref   = $this->getNewXref();
740        $gedcom = '0 @' . $xref . '@ OBJE' . Str::after($gedcom, '0 @@ OBJE');
741
742        // Create a change record
743        $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName();
744
745        // Create a pending change
746        DB::table('change')->insert([
747            'gedcom_id'  => $this->id,
748            'xref'       => $xref,
749            'old_gedcom' => '',
750            'new_gedcom' => $gedcom,
751            'user_id'    => Auth::id(),
752        ]);
753
754        // Accept this pending change
755        if (Auth::user()->getPreference('auto_accept')) {
756            FunctionsImport::acceptAllChanges($xref, $this);
757
758            return new Media($xref, $gedcom, null, $this);
759        }
760
761        return new Media($xref, '', $gedcom, $this);
762    }
763
764    /**
765     * What is the most significant individual in this tree.
766     *
767     * @param UserInterface $user
768     *
769     * @return Individual
770     */
771    public function significantIndividual(UserInterface $user): Individual
772    {
773        $individual = null;
774
775        if ($this->getUserPreference($user, 'rootid') !== '') {
776            $individual = Individual::getInstance($this->getUserPreference($user, 'rootid'), $this);
777        }
778
779        if ($individual === null && $this->getUserPreference($user, 'gedcomid') !== '') {
780            $individual = Individual::getInstance($this->getUserPreference($user, 'gedcomid'), $this);
781        }
782
783        if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') {
784            $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this);
785        }
786        if ($individual === null) {
787            $xref = (string) DB::table('individuals')
788                ->where('i_file', '=', $this->id())
789                ->min('i_id');
790
791            $individual = Individual::getInstance($xref, $this);
792        }
793        if ($individual === null) {
794            // always return a record
795            $individual = new Individual('I', '0 @I@ INDI', null, $this);
796        }
797
798        return $individual;
799    }
800
801    /**
802     * Where do we store our media files.
803     *
804     * @return FilesystemInterface
805     */
806    public function mediaFilesystem(): FilesystemInterface
807    {
808        $media_dir  = $this->getPreference('MEDIA_DIRECTORY', 'media/');
809        $filesystem = app(FilesystemInterface::class);
810        $adapter    = new ChrootAdapter($filesystem, $media_dir);
811
812        return new Filesystem($adapter);
813    }
814}
815