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