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