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