xref: /webtrees/app/Tree.php (revision b3679361640bf9d3bb5424fb15815c51cf0c1513)
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\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 date;
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        'FULL_SOURCES'                 => '0',
59        'GEDCOM_MEDIA_PATH'            => '',
60        'GENERATE_UIDS'                => '0',
61        'HIDE_GEDCOM_ERRORS'           => '1',
62        'HIDE_LIVE_PEOPLE'             => '1',
63        'INDI_FACTS_QUICK'             => 'BIRT,BURI,BAPM,CENS,DEAT,OCCU,RESI',
64        'KEEP_ALIVE_YEARS_BIRTH'       => '',
65        'KEEP_ALIVE_YEARS_DEATH'       => '',
66        'LANGUAGE'                     => 'en-US',
67        'MAX_ALIVE_AGE'                => '120',
68        'MEDIA_DIRECTORY'              => 'media/',
69        'MEDIA_UPLOAD'                 => '1', // Auth::PRIV_USER
70        'META_DESCRIPTION'             => '',
71        'META_TITLE'                   => Webtrees::NAME,
72        'NO_UPDATE_CHAN'               => '0',
73        'PEDIGREE_ROOT_ID'             => '',
74        'PREFER_LEVEL2_SOURCES'        => '1',
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 function (object $row): Tree {
168            return new Tree((int) $row->tree_id, $row->tree_name, $row->tree_title);
169        };
170    }
171
172    /**
173     * Set the tree’s configuration settings.
174     *
175     * @param string $setting_name
176     * @param string $setting_value
177     *
178     * @return $this
179     */
180    public function setPreference(string $setting_name, string $setting_value): Tree
181    {
182        if ($setting_value !== $this->getPreference($setting_name)) {
183            DB::table('gedcom_setting')->updateOrInsert([
184                'gedcom_id'    => $this->id,
185                'setting_name' => $setting_name,
186            ], [
187                'setting_value' => $setting_value,
188            ]);
189
190            $this->preferences[$setting_name] = $setting_value;
191
192            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this);
193        }
194
195        return $this;
196    }
197
198    /**
199     * Get the tree’s configuration settings.
200     *
201     * @param string      $setting_name
202     * @param string|null $default
203     *
204     * @return string
205     */
206    public function getPreference(string $setting_name, string $default = null): string
207    {
208        if ($this->preferences === []) {
209            $this->preferences = DB::table('gedcom_setting')
210                ->where('gedcom_id', '=', $this->id)
211                ->pluck('setting_value', 'setting_name')
212                ->all();
213        }
214
215        return $this->preferences[$setting_name] ?? $default ?? self::DEFAULT_PREFERENCES[$setting_name] ?? '';
216    }
217
218    /**
219     * The name of this tree
220     *
221     * @return string
222     */
223    public function name(): string
224    {
225        return $this->name;
226    }
227
228    /**
229     * The title of this tree
230     *
231     * @return string
232     */
233    public function title(): string
234    {
235        return $this->title;
236    }
237
238    /**
239     * The fact-level privacy for this tree.
240     *
241     * @return array<int>
242     */
243    public function getFactPrivacy(): array
244    {
245        return $this->fact_privacy;
246    }
247
248    /**
249     * The individual-level privacy for this tree.
250     *
251     * @return array<int>
252     */
253    public function getIndividualPrivacy(): array
254    {
255        return $this->individual_privacy;
256    }
257
258    /**
259     * The individual-fact-level privacy for this tree.
260     *
261     * @return array<array<int>>
262     */
263    public function getIndividualFactPrivacy(): array
264    {
265        return $this->individual_fact_privacy;
266    }
267
268    /**
269     * Set the tree’s user-configuration settings.
270     *
271     * @param UserInterface $user
272     * @param string        $setting_name
273     * @param string        $setting_value
274     *
275     * @return $this
276     */
277    public function setUserPreference(UserInterface $user, string $setting_name, string $setting_value): Tree
278    {
279        if ($this->getUserPreference($user, $setting_name) !== $setting_value) {
280            // Update the database
281            DB::table('user_gedcom_setting')->updateOrInsert([
282                'gedcom_id'    => $this->id(),
283                'user_id'      => $user->id(),
284                'setting_name' => $setting_name,
285            ], [
286                'setting_value' => $setting_value,
287            ]);
288
289            // Update the cache
290            $this->user_preferences[$user->id()][$setting_name] = $setting_value;
291            // Audit log of changes
292            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->userName() . '"', $this);
293        }
294
295        return $this;
296    }
297
298    /**
299     * Get the tree’s user-configuration settings.
300     *
301     * @param UserInterface $user
302     * @param string        $setting_name
303     * @param string        $default
304     *
305     * @return string
306     */
307    public function getUserPreference(UserInterface $user, string $setting_name, string $default = ''): string
308    {
309        // There are lots of settings, and we need to fetch lots of them on every page
310        // so it is quicker to fetch them all in one go.
311        if (!array_key_exists($user->id(), $this->user_preferences)) {
312            $this->user_preferences[$user->id()] = DB::table('user_gedcom_setting')
313                ->where('user_id', '=', $user->id())
314                ->where('gedcom_id', '=', $this->id)
315                ->pluck('setting_value', 'setting_name')
316                ->all();
317        }
318
319        return $this->user_preferences[$user->id()][$setting_name] ?? $default;
320    }
321
322    /**
323     * The ID of this tree
324     *
325     * @return int
326     */
327    public function id(): int
328    {
329        return $this->id;
330    }
331
332    /**
333     * Can a user accept changes for this tree?
334     *
335     * @param UserInterface $user
336     *
337     * @return bool
338     */
339    public function canAcceptChanges(UserInterface $user): bool
340    {
341        return Auth::isModerator($this, $user);
342    }
343
344    /**
345     * Are there any pending edits for this tree, than need reviewing by a moderator.
346     *
347     * @return bool
348     */
349    public function hasPendingEdit(): bool
350    {
351        return DB::table('change')
352            ->where('gedcom_id', '=', $this->id)
353            ->where('status', '=', 'pending')
354            ->exists();
355    }
356
357    /**
358     * Create a new record from GEDCOM data.
359     *
360     * @param string $gedcom
361     *
362     * @return GedcomRecord|Individual|Family|Location|Note|Source|Repository|Media|Submitter|Submission
363     * @throws InvalidArgumentException
364     */
365    public function createRecord(string $gedcom): GedcomRecord
366    {
367        if (!preg_match('/^0 @@ ([_A-Z]+)/', $gedcom, $match)) {
368            throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@');
369        }
370
371        $xref   = Registry::xrefFactory()->make($match[1]);
372        $gedcom = substr_replace($gedcom, $xref, 3, 0);
373
374        // Create a change record
375        $today = strtoupper(date('d M Y'));
376        $now   = date('H:i:s');
377        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
378
379        // Create a pending change
380        DB::table('change')->insert([
381            'gedcom_id'  => $this->id,
382            'xref'       => $xref,
383            'old_gedcom' => '',
384            'new_gedcom' => $gedcom,
385            'user_id'    => Auth::id(),
386        ]);
387
388        // Accept this pending change
389        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
390            $record = Registry::gedcomRecordFactory()->new($xref, $gedcom, null, $this);
391
392            app(PendingChangesService::class)->acceptRecord($record);
393
394            return $record;
395        }
396
397        return Registry::gedcomRecordFactory()->new($xref, '', $gedcom, $this);
398    }
399
400    /**
401     * Create a new family from GEDCOM data.
402     *
403     * @param string $gedcom
404     *
405     * @return Family
406     * @throws InvalidArgumentException
407     */
408    public function createFamily(string $gedcom): GedcomRecord
409    {
410        if (!str_starts_with($gedcom, '0 @@ FAM')) {
411            throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM');
412        }
413
414        $xref   = Registry::xrefFactory()->make(Family::RECORD_TYPE);
415        $gedcom = substr_replace($gedcom, $xref, 3, 0);
416
417        // Create a change record
418        $today = strtoupper(date('d M Y'));
419        $now   = date('H:i:s');
420        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
421
422        // Create a pending change
423        DB::table('change')->insert([
424            'gedcom_id'  => $this->id,
425            'xref'       => $xref,
426            'old_gedcom' => '',
427            'new_gedcom' => $gedcom,
428            'user_id'    => Auth::id(),
429        ]);
430
431        // Accept this pending change
432        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
433            $record = Registry::familyFactory()->new($xref, $gedcom, null, $this);
434
435            app(PendingChangesService::class)->acceptRecord($record);
436
437            return $record;
438        }
439
440        return Registry::familyFactory()->new($xref, '', $gedcom, $this);
441    }
442
443    /**
444     * Create a new individual from GEDCOM data.
445     *
446     * @param string $gedcom
447     *
448     * @return Individual
449     * @throws InvalidArgumentException
450     */
451    public function createIndividual(string $gedcom): GedcomRecord
452    {
453        if (!str_starts_with($gedcom, '0 @@ INDI')) {
454            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI');
455        }
456
457        $xref   = Registry::xrefFactory()->make(Individual::RECORD_TYPE);
458        $gedcom = substr_replace($gedcom, $xref, 3, 0);
459
460        // Create a change record
461        $today = strtoupper(date('d M Y'));
462        $now   = date('H:i:s');
463        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
464
465        // Create a pending change
466        DB::table('change')->insert([
467            'gedcom_id'  => $this->id,
468            'xref'       => $xref,
469            'old_gedcom' => '',
470            'new_gedcom' => $gedcom,
471            'user_id'    => Auth::id(),
472        ]);
473
474        // Accept this pending change
475        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
476            $record = Registry::individualFactory()->new($xref, $gedcom, null, $this);
477
478            app(PendingChangesService::class)->acceptRecord($record);
479
480            return $record;
481        }
482
483        return Registry::individualFactory()->new($xref, '', $gedcom, $this);
484    }
485
486    /**
487     * Create a new media object from GEDCOM data.
488     *
489     * @param string $gedcom
490     *
491     * @return Media
492     * @throws InvalidArgumentException
493     */
494    public function createMediaObject(string $gedcom): Media
495    {
496        if (!str_starts_with($gedcom, '0 @@ OBJE')) {
497            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE');
498        }
499
500        $xref   = Registry::xrefFactory()->make(Media::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::mediaFactory()->new($xref, $gedcom, null, $this);
520
521            app(PendingChangesService::class)->acceptRecord($record);
522
523            return $record;
524        }
525
526        return Registry::mediaFactory()->new($xref, '', $gedcom, $this);
527    }
528
529    /**
530     * What is the most significant individual in this tree.
531     *
532     * @param UserInterface $user
533     * @param string        $xref
534     *
535     * @return Individual
536     */
537    public function significantIndividual(UserInterface $user, string $xref = ''): Individual
538    {
539        if ($xref === '') {
540            $individual = null;
541        } else {
542            $individual = Registry::individualFactory()->make($xref, $this);
543
544            if ($individual === null) {
545                $family = Registry::familyFactory()->make($xref, $this);
546
547                if ($family instanceof Family) {
548                    $individual = $family->spouses()->first() ?? $family->children()->first();
549                }
550            }
551        }
552
553        if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF) !== '') {
554            $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF), $this);
555        }
556
557        if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF) !== '') {
558            $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF), $this);
559        }
560
561        if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') {
562            $individual = Registry::individualFactory()->make($this->getPreference('PEDIGREE_ROOT_ID'), $this);
563        }
564        if ($individual === null) {
565            $xref = (string) DB::table('individuals')
566                ->where('i_file', '=', $this->id())
567                ->min('i_id');
568
569            $individual = Registry::individualFactory()->make($xref, $this);
570        }
571        if ($individual === null) {
572            // always return a record
573            $individual = Registry::individualFactory()->new('I', '0 @I@ INDI', null, $this);
574        }
575
576        return $individual;
577    }
578
579    /**
580     * Where do we store our media files.
581     *
582     * @param FilesystemOperator $data_filesystem
583     *
584     * @return FilesystemOperator
585     */
586    public function mediaFilesystem(FilesystemOperator $data_filesystem): FilesystemOperator
587    {
588        $media_dir = $this->getPreference('MEDIA_DIRECTORY');
589        $adapter   = new ChrootAdapter($data_filesystem, $media_dir);
590
591        return new Filesystem($adapter);
592    }
593}
594