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