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