Issues (2502)

app/Tree.php (1 issue)

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