xref: /webtrees/app/Tree.php (revision 5cd281f4f76e660b2e033b96db47543fa16f7748)
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\GedcomExportService;
26use Fisharebest\Webtrees\Services\PendingChangesService;
27use Fisharebest\Webtrees\Services\TreeService;
28use Illuminate\Database\Capsule\Manager as DB;
29use InvalidArgumentException;
30use League\Flysystem\Filesystem;
31use League\Flysystem\FilesystemInterface;
32use Psr\Http\Message\StreamInterface;
33use stdClass;
34
35use function app;
36use function array_key_exists;
37use function date;
38use function str_starts_with;
39use function strlen;
40use function strtoupper;
41use function substr;
42use function substr_replace;
43
44/**
45 * Provide an interface to the wt_gedcom table.
46 */
47class Tree
48{
49    private const RESN_PRIVACY = [
50        'none'         => Auth::PRIV_PRIVATE,
51        'privacy'      => Auth::PRIV_USER,
52        'confidential' => Auth::PRIV_NONE,
53        'hidden'       => Auth::PRIV_HIDE,
54    ];
55
56    /** @var int The tree's ID number */
57    private $id;
58
59    /** @var string The tree's name */
60    private $name;
61
62    /** @var string The tree's title */
63    private $title;
64
65    /** @var int[] Default access rules for facts in this tree */
66    private $fact_privacy;
67
68    /** @var int[] Default access rules for individuals in this tree */
69    private $individual_privacy;
70
71    /** @var integer[][] Default access rules for individual facts in this tree */
72    private $individual_fact_privacy;
73
74    /** @var string[] Cached copy of the wt_gedcom_setting table. */
75    private $preferences = [];
76
77    /** @var string[][] Cached copy of the wt_user_gedcom_setting table. */
78    private $user_preferences = [];
79
80    /**
81     * Create a tree object.
82     *
83     * @param int    $id
84     * @param string $name
85     * @param string $title
86     */
87    public function __construct(int $id, string $name, string $title)
88    {
89        $this->id                      = $id;
90        $this->name                    = $name;
91        $this->title                   = $title;
92        $this->fact_privacy            = [];
93        $this->individual_privacy      = [];
94        $this->individual_fact_privacy = [];
95
96        // Load the privacy settings for this tree
97        $rows = DB::table('default_resn')
98            ->where('gedcom_id', '=', $this->id)
99            ->get();
100
101        foreach ($rows as $row) {
102            // Convert GEDCOM privacy restriction to a webtrees access level.
103            $row->resn = self::RESN_PRIVACY[$row->resn];
104
105            if ($row->xref !== null) {
106                if ($row->tag_type !== null) {
107                    $this->individual_fact_privacy[$row->xref][$row->tag_type] = $row->resn;
108                } else {
109                    $this->individual_privacy[$row->xref] = $row->resn;
110                }
111            } else {
112                $this->fact_privacy[$row->tag_type] = $row->resn;
113            }
114        }
115    }
116
117    /**
118     * A closure which will create a record from a database row.
119     *
120     * @return Closure
121     */
122    public static function rowMapper(): Closure
123    {
124        return static function (stdClass $row): Tree {
125            return new Tree((int) $row->tree_id, $row->tree_name, $row->tree_title);
126        };
127    }
128
129    /**
130     * Set the tree’s configuration settings.
131     *
132     * @param string $setting_name
133     * @param string $setting_value
134     *
135     * @return $this
136     */
137    public function setPreference(string $setting_name, string $setting_value): Tree
138    {
139        if ($setting_value !== $this->getPreference($setting_name)) {
140            DB::table('gedcom_setting')->updateOrInsert([
141                'gedcom_id'    => $this->id,
142                'setting_name' => $setting_name,
143            ], [
144                'setting_value' => $setting_value,
145            ]);
146
147            $this->preferences[$setting_name] = $setting_value;
148
149            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this);
150        }
151
152        return $this;
153    }
154
155    /**
156     * Get the tree’s configuration settings.
157     *
158     * @param string $setting_name
159     * @param string $default
160     *
161     * @return string
162     */
163    public function getPreference(string $setting_name, string $default = ''): string
164    {
165        if ($this->preferences === []) {
166            $this->preferences = DB::table('gedcom_setting')
167                ->where('gedcom_id', '=', $this->id)
168                ->pluck('setting_value', 'setting_name')
169                ->all();
170        }
171
172        return $this->preferences[$setting_name] ?? $default;
173    }
174
175    /**
176     * The name of this tree
177     *
178     * @return string
179     */
180    public function name(): string
181    {
182        return $this->name;
183    }
184
185    /**
186     * The title of this tree
187     *
188     * @return string
189     */
190    public function title(): string
191    {
192        return $this->title;
193    }
194
195    /**
196     * The fact-level privacy for this tree.
197     *
198     * @return int[]
199     */
200    public function getFactPrivacy(): array
201    {
202        return $this->fact_privacy;
203    }
204
205    /**
206     * The individual-level privacy for this tree.
207     *
208     * @return int[]
209     */
210    public function getIndividualPrivacy(): array
211    {
212        return $this->individual_privacy;
213    }
214
215    /**
216     * The individual-fact-level privacy for this tree.
217     *
218     * @return int[][]
219     */
220    public function getIndividualFactPrivacy(): array
221    {
222        return $this->individual_fact_privacy;
223    }
224
225    /**
226     * Set the tree’s user-configuration settings.
227     *
228     * @param UserInterface $user
229     * @param string        $setting_name
230     * @param string        $setting_value
231     *
232     * @return $this
233     */
234    public function setUserPreference(UserInterface $user, string $setting_name, string $setting_value): Tree
235    {
236        if ($this->getUserPreference($user, $setting_name) !== $setting_value) {
237            // Update the database
238            DB::table('user_gedcom_setting')->updateOrInsert([
239                'gedcom_id'    => $this->id(),
240                'user_id'      => $user->id(),
241                'setting_name' => $setting_name,
242            ], [
243                'setting_value' => $setting_value,
244            ]);
245
246            // Update the cache
247            $this->user_preferences[$user->id()][$setting_name] = $setting_value;
248            // Audit log of changes
249            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->userName() . '"', $this);
250        }
251
252        return $this;
253    }
254
255    /**
256     * Get the tree’s user-configuration settings.
257     *
258     * @param UserInterface $user
259     * @param string        $setting_name
260     * @param string        $default
261     *
262     * @return string
263     */
264    public function getUserPreference(UserInterface $user, string $setting_name, string $default = ''): string
265    {
266        // There are lots of settings, and we need to fetch lots of them on every page
267        // so it is quicker to fetch them all in one go.
268        if (!array_key_exists($user->id(), $this->user_preferences)) {
269            $this->user_preferences[$user->id()] = DB::table('user_gedcom_setting')
270                ->where('user_id', '=', $user->id())
271                ->where('gedcom_id', '=', $this->id)
272                ->pluck('setting_value', 'setting_name')
273                ->all();
274        }
275
276        return $this->user_preferences[$user->id()][$setting_name] ?? $default;
277    }
278
279    /**
280     * The ID of this tree
281     *
282     * @return int
283     */
284    public function id(): int
285    {
286        return $this->id;
287    }
288
289    /**
290     * Can a user accept changes for this tree?
291     *
292     * @param UserInterface $user
293     *
294     * @return bool
295     */
296    public function canAcceptChanges(UserInterface $user): bool
297    {
298        return Auth::isModerator($this, $user);
299    }
300
301    /**
302     * Are there any pending edits for this tree, than need reviewing by a moderator.
303     *
304     * @return bool
305     */
306    public function hasPendingEdit(): bool
307    {
308        return DB::table('change')
309            ->where('gedcom_id', '=', $this->id)
310            ->where('status', '=', 'pending')
311            ->exists();
312    }
313
314    /**
315     * Delete everything relating to a tree
316     *
317     * @return void
318     *
319     * @deprecated - since 2.0.12 - will be removed in 2.1.0
320     */
321    public function delete(): void
322    {
323        $tree_service = new TreeService();
324
325        $tree_service->delete($this);
326    }
327
328    /**
329     * Delete all the genealogy data from a tree - in preparation for importing
330     * new data. Optionally retain the media data, for when the user has been
331     * editing their data offline using an application which deletes (or does not
332     * support) media data.
333     *
334     * @param bool $keep_media
335     *
336     * @return void
337     *
338     * @deprecated - since 2.0.12 - will be removed in 2.1.0
339     */
340    public function deleteGenealogyData(bool $keep_media): void
341    {
342        $tree_service = new TreeService();
343
344        $tree_service->deleteGenealogyData($this, $keep_media);
345    }
346
347    /**
348     * Export the tree to a GEDCOM file
349     *
350     * @param resource $stream
351     *
352     * @return void
353     *
354     * @deprecated since 2.0.5.  Will be removed in 2.1.0
355     */
356    public function exportGedcom($stream): void
357    {
358        $gedcom_export_service = new GedcomExportService();
359
360        $gedcom_export_service->export($this, $stream);
361    }
362
363    /**
364     * Import data from a gedcom file into this tree.
365     *
366     * @param StreamInterface $stream   The GEDCOM file.
367     * @param string          $filename The preferred filename, for export/download.
368     *
369     * @return void
370     *
371     * @deprecated since 2.0.12.  Will be removed in 2.1.0
372     */
373    public function importGedcomFile(StreamInterface $stream, string $filename): void
374    {
375        $tree_service = new TreeService();
376
377        $tree_service->importGedcomFile($this, $stream, $filename);
378    }
379
380    /**
381     * Create a new record from GEDCOM data.
382     *
383     * @param string $gedcom
384     *
385     * @return GedcomRecord|Individual|Family|Location|Note|Source|Repository|Media|Submitter|Submission
386     * @throws InvalidArgumentException
387     */
388    public function createRecord(string $gedcom): GedcomRecord
389    {
390        if (!preg_match('/^0 @@ ([_A-Z]+)/', $gedcom, $match)) {
391            throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@');
392        }
393
394        $xref   = Registry::xrefFactory()->make($match[1]);
395        $gedcom = substr_replace($gedcom, $xref, 3, 0);
396
397        // Create a change record
398        $today = strtoupper(date('d M Y'));
399        $now   = date('H:i:s');
400        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
401
402        // Create a pending change
403        DB::table('change')->insert([
404            'gedcom_id'  => $this->id,
405            'xref'       => $xref,
406            'old_gedcom' => '',
407            'new_gedcom' => $gedcom,
408            'user_id'    => Auth::id(),
409        ]);
410
411        // Accept this pending change
412        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
413            $record = Registry::gedcomRecordFactory()->new($xref, $gedcom, null, $this);
414
415            app(PendingChangesService::class)->acceptRecord($record);
416
417            return $record;
418        }
419
420        return Registry::gedcomRecordFactory()->new($xref, '', $gedcom, $this);
421    }
422
423    /**
424     * Generate a new XREF, unique across all family trees
425     *
426     * @return string
427     * @deprecated - use the factory directly.
428     */
429    public function getNewXref(): string
430    {
431        return Registry::xrefFactory()->make(GedcomRecord::RECORD_TYPE);
432    }
433
434    /**
435     * Create a new family from GEDCOM data.
436     *
437     * @param string $gedcom
438     *
439     * @return Family
440     * @throws InvalidArgumentException
441     */
442    public function createFamily(string $gedcom): GedcomRecord
443    {
444        if (!str_starts_with($gedcom, '0 @@ FAM')) {
445            throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM');
446        }
447
448        $xref   = Registry::xrefFactory()->make(Family::RECORD_TYPE);
449        $gedcom = substr_replace($gedcom, $xref, 3, 0);
450
451        // Create a change record
452        $today = strtoupper(date('d M Y'));
453        $now   = date('H:i:s');
454        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
455
456        // Create a pending change
457        DB::table('change')->insert([
458            'gedcom_id'  => $this->id,
459            'xref'       => $xref,
460            'old_gedcom' => '',
461            'new_gedcom' => $gedcom,
462            'user_id'    => Auth::id(),
463        ]);
464
465        // Accept this pending change
466        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
467            $record = Registry::familyFactory()->new($xref, $gedcom, null, $this);
468
469            app(PendingChangesService::class)->acceptRecord($record);
470
471            return $record;
472        }
473
474        return Registry::familyFactory()->new($xref, '', $gedcom, $this);
475    }
476
477    /**
478     * Create a new individual from GEDCOM data.
479     *
480     * @param string $gedcom
481     *
482     * @return Individual
483     * @throws InvalidArgumentException
484     */
485    public function createIndividual(string $gedcom): GedcomRecord
486    {
487        if (!str_starts_with($gedcom, '0 @@ INDI')) {
488            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI');
489        }
490
491        $xref   = Registry::xrefFactory()->make(Individual::RECORD_TYPE);
492        $gedcom = substr_replace($gedcom, $xref, 3, 0);
493
494        // Create a change record
495        $today = strtoupper(date('d M Y'));
496        $now   = date('H:i:s');
497        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
498
499        // Create a pending change
500        DB::table('change')->insert([
501            'gedcom_id'  => $this->id,
502            'xref'       => $xref,
503            'old_gedcom' => '',
504            'new_gedcom' => $gedcom,
505            'user_id'    => Auth::id(),
506        ]);
507
508        // Accept this pending change
509        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
510            $record = Registry::individualFactory()->new($xref, $gedcom, null, $this);
511
512            app(PendingChangesService::class)->acceptRecord($record);
513
514            return $record;
515        }
516
517        return Registry::individualFactory()->new($xref, '', $gedcom, $this);
518    }
519
520    /**
521     * Create a new media object from GEDCOM data.
522     *
523     * @param string $gedcom
524     *
525     * @return Media
526     * @throws InvalidArgumentException
527     */
528    public function createMediaObject(string $gedcom): Media
529    {
530        if (!str_starts_with($gedcom, '0 @@ OBJE')) {
531            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE');
532        }
533
534        $xref   = Registry::xrefFactory()->make(Media::RECORD_TYPE);
535        $gedcom = substr_replace($gedcom, $xref, 3, 0);
536
537        // Create a change record
538        $today = strtoupper(date('d M Y'));
539        $now   = date('H:i:s');
540        $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName();
541
542        // Create a pending change
543        DB::table('change')->insert([
544            'gedcom_id'  => $this->id,
545            'xref'       => $xref,
546            'old_gedcom' => '',
547            'new_gedcom' => $gedcom,
548            'user_id'    => Auth::id(),
549        ]);
550
551        // Accept this pending change
552        if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') {
553            $record = Registry::mediaFactory()->new($xref, $gedcom, null, $this);
554
555            app(PendingChangesService::class)->acceptRecord($record);
556
557            return $record;
558        }
559
560        return Registry::mediaFactory()->new($xref, '', $gedcom, $this);
561    }
562
563    /**
564     * What is the most significant individual in this tree.
565     *
566     * @param UserInterface $user
567     * @param string        $xref
568     *
569     * @return Individual
570     */
571    public function significantIndividual(UserInterface $user, $xref = ''): Individual
572    {
573        if ($xref === '') {
574            $individual = null;
575        } else {
576            $individual = Registry::individualFactory()->make($xref, $this);
577
578            if ($individual === null) {
579                $family = Registry::familyFactory()->make($xref, $this);
580
581                if ($family instanceof Family) {
582                    $individual = $family->spouses()->first() ?? $family->children()->first();
583                }
584            }
585        }
586
587        if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF) !== '') {
588            $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF), $this);
589        }
590
591        if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF) !== '') {
592            $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF), $this);
593        }
594
595        if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') {
596            $individual = Registry::individualFactory()->make($this->getPreference('PEDIGREE_ROOT_ID'), $this);
597        }
598        if ($individual === null) {
599            $xref = (string) DB::table('individuals')
600                ->where('i_file', '=', $this->id())
601                ->min('i_id');
602
603            $individual = Registry::individualFactory()->make($xref, $this);
604        }
605        if ($individual === null) {
606            // always return a record
607            $individual = Registry::individualFactory()->new('I', '0 @I@ INDI', null, $this);
608        }
609
610        return $individual;
611    }
612
613    /**
614     * Where do we store our media files.
615     *
616     * @param FilesystemInterface $data_filesystem
617     *
618     * @return FilesystemInterface
619     */
620    public function mediaFilesystem(FilesystemInterface $data_filesystem): FilesystemInterface
621    {
622        $media_dir = $this->getPreference('MEDIA_DIRECTORY', 'media/');
623        $adapter   = new ChrootAdapter($data_filesystem, $media_dir);
624
625        return new Filesystem($adapter);
626    }
627}
628