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