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