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