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