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