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