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