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