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