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