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