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