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