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