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