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