xref: /webtrees/app/Tree.php (revision 708b06c1245be7c6dea40826c3ffd62805f375b0)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2018 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16declare(strict_types=1);
17
18namespace Fisharebest\Webtrees;
19
20use Exception;
21use Fisharebest\Webtrees\Functions\FunctionsExport;
22use Fisharebest\Webtrees\Functions\FunctionsImport;
23use InvalidArgumentException;
24use PDOException;
25use function substr_compare;
26
27/**
28 * Provide an interface to the wt_gedcom table.
29 */
30class Tree
31{
32    /** @var int The tree's ID number */
33    private $tree_id;
34
35    /** @var string The tree's name */
36    private $name;
37
38    /** @var string The tree's title */
39    private $title;
40
41    /** @var int[] Default access rules for facts in this tree */
42    private $fact_privacy;
43
44    /** @var int[] Default access rules for individuals in this tree */
45    private $individual_privacy;
46
47    /** @var integer[][] Default access rules for individual facts in this tree */
48    private $individual_fact_privacy;
49
50    /** @var Tree[] All trees that we have permission to see. */
51    private static $trees = [];
52
53    /** @var string[] Cached copy of the wt_gedcom_setting table. */
54    private $preferences = [];
55
56    /** @var string[][] Cached copy of the wt_user_gedcom_setting table. */
57    private $user_preferences = [];
58
59    /**
60     * Create a tree object. This is a private constructor - it can only
61     * be called from Tree::getAll() to ensure proper initialisation.
62     *
63     * @param int    $tree_id
64     * @param string $tree_name
65     * @param string $tree_title
66     */
67    private function __construct($tree_id, $tree_name, $tree_title)
68    {
69        $this->tree_id                 = $tree_id;
70        $this->name                    = $tree_name;
71        $this->title                   = $tree_title;
72        $this->fact_privacy            = [];
73        $this->individual_privacy      = [];
74        $this->individual_fact_privacy = [];
75
76        // Load the privacy settings for this tree
77        $rows = Database::prepare(
78            "SELECT xref, tag_type, CASE resn WHEN 'none' THEN :priv_public WHEN 'privacy' THEN :priv_user WHEN 'confidential' THEN :priv_none WHEN 'hidden' THEN :priv_hide END AS resn" .
79            " FROM `##default_resn` WHERE gedcom_id = :tree_id"
80        )->execute([
81            'priv_public' => Auth::PRIV_PRIVATE,
82            'priv_user'   => Auth::PRIV_USER,
83            'priv_none'   => Auth::PRIV_NONE,
84            'priv_hide'   => Auth::PRIV_HIDE,
85            'tree_id'     => $this->tree_id,
86        ])->fetchAll();
87
88        foreach ($rows as $row) {
89            if ($row->xref !== null) {
90                if ($row->tag_type !== null) {
91                    $this->individual_fact_privacy[$row->xref][$row->tag_type] = (int) $row->resn;
92                } else {
93                    $this->individual_privacy[$row->xref] = (int) $row->resn;
94                }
95            } else {
96                $this->fact_privacy[$row->tag_type] = (int) $row->resn;
97            }
98        }
99    }
100
101    /**
102     * The ID of this tree
103     *
104     * @return int
105     */
106    public function getTreeId(): int
107    {
108        return $this->tree_id;
109    }
110
111    /**
112     * The name of this tree
113     *
114     * @return string
115     */
116    public function getName(): string
117    {
118        return $this->name;
119    }
120
121    /**
122     * The title of this tree
123     *
124     * @return string
125     */
126    public function getTitle(): string
127    {
128        return $this->title;
129    }
130
131    /**
132     * The fact-level privacy for this tree.
133     *
134     * @return int[]
135     */
136    public function getFactPrivacy(): array
137    {
138        return $this->fact_privacy;
139    }
140
141    /**
142     * The individual-level privacy for this tree.
143     *
144     * @return int[]
145     */
146    public function getIndividualPrivacy(): array
147    {
148        return $this->individual_privacy;
149    }
150
151    /**
152     * The individual-fact-level privacy for this tree.
153     *
154     * @return int[][]
155     */
156    public function getIndividualFactPrivacy(): array
157    {
158        return $this->individual_fact_privacy;
159    }
160
161    /**
162     * Get the tree’s configuration settings.
163     *
164     * @param string $setting_name
165     * @param string $default
166     *
167     * @return string
168     */
169    public function getPreference(string $setting_name, string $default = ''): string
170    {
171        if (empty($this->preferences)) {
172            $this->preferences = Database::prepare(
173                "SELECT setting_name, setting_value FROM `##gedcom_setting` WHERE gedcom_id = ?"
174            )->execute([$this->tree_id])->fetchAssoc();
175        }
176
177        return $this->preferences[$setting_name] ?? $default;
178    }
179
180    /**
181     * Set the tree’s configuration settings.
182     *
183     * @param string $setting_name
184     * @param string $setting_value
185     *
186     * @return $this
187     */
188    public function setPreference(string $setting_name, string $setting_value): Tree
189    {
190        if ($setting_value !== $this->getPreference($setting_name)) {
191            Database::prepare(
192                "REPLACE INTO `##gedcom_setting` (gedcom_id, setting_name, setting_value)" .
193                " VALUES (:tree_id, :setting_name, LEFT(:setting_value, 255))"
194            )->execute([
195                'tree_id'       => $this->tree_id,
196                'setting_name'  => $setting_name,
197                'setting_value' => $setting_value,
198            ]);
199
200            $this->preferences[$setting_name] = $setting_value;
201
202            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this);
203        }
204
205        return $this;
206    }
207
208    /**
209     * Get the tree’s user-configuration settings.
210     *
211     * @param User   $user
212     * @param string $setting_name
213     * @param string $default
214     *
215     * @return string
216     */
217    public function getUserPreference(User $user, string $setting_name, string $default = ''): string
218    {
219        // There are lots of settings, and we need to fetch lots of them on every page
220        // so it is quicker to fetch them all in one go.
221        if (!array_key_exists($user->getUserId(), $this->user_preferences)) {
222            $this->user_preferences[$user->getUserId()] = Database::prepare(
223                "SELECT setting_name, setting_value FROM `##user_gedcom_setting` WHERE user_id = ? AND gedcom_id = ?"
224            )->execute([
225                $user->getUserId(),
226                $this->tree_id,
227            ])->fetchAssoc();
228        }
229
230        return $this->user_preferences[$user->getUserId()][$setting_name] ?? $default;
231    }
232
233    /**
234     * Set the tree’s user-configuration settings.
235     *
236     * @param User   $user
237     * @param string $setting_name
238     * @param string $setting_value
239     *
240     * @return $this
241     */
242    public function setUserPreference(User $user, string $setting_name, string $setting_value): Tree
243    {
244        if ($this->getUserPreference($user, $setting_name) !== $setting_value) {
245            // Update the database
246            if ($setting_value === '') {
247                Database::prepare(
248                    "DELETE FROM `##user_gedcom_setting` WHERE gedcom_id = :tree_id AND user_id = :user_id AND setting_name = :setting_name"
249                )->execute([
250                    'tree_id'      => $this->tree_id,
251                    'user_id'      => $user->getUserId(),
252                    'setting_name' => $setting_name,
253                ]);
254            } else {
255                Database::prepare(
256                    "REPLACE INTO `##user_gedcom_setting` (user_id, gedcom_id, setting_name, setting_value) VALUES (:user_id, :tree_id, :setting_name, LEFT(:setting_value, 255))"
257                )->execute([
258                    'user_id'       => $user->getUserId(),
259                    'tree_id'       => $this->tree_id,
260                    'setting_name'  => $setting_name,
261                    'setting_value' => $setting_value,
262                ]);
263            }
264            // Update our cache
265            $this->user_preferences[$user->getUserId()][$setting_name] = $setting_value;
266            // Audit log of changes
267            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->getUserName() . '"', $this);
268        }
269
270        return $this;
271    }
272
273    /**
274     * Can a user accept changes for this tree?
275     *
276     * @param User $user
277     *
278     * @return bool
279     */
280    public function canAcceptChanges(User $user): bool
281    {
282        return Auth::isModerator($this, $user);
283    }
284
285    /**
286     * Fetch all the trees that we have permission to access.
287     *
288     * @return Tree[]
289     */
290    public static function getAll(): array
291    {
292        if (empty(self::$trees)) {
293            $rows = Database::prepare(
294                "SELECT g.gedcom_id AS tree_id, g.gedcom_name AS tree_name, gs1.setting_value AS tree_title" .
295                " FROM `##gedcom` g" .
296                " LEFT JOIN `##gedcom_setting`      gs1 ON (g.gedcom_id=gs1.gedcom_id AND gs1.setting_name='title')" .
297                " LEFT JOIN `##gedcom_setting`      gs2 ON (g.gedcom_id=gs2.gedcom_id AND gs2.setting_name='imported')" .
298                " LEFT JOIN `##gedcom_setting`      gs3 ON (g.gedcom_id=gs3.gedcom_id AND gs3.setting_name='REQUIRE_AUTHENTICATION')" .
299                " LEFT JOIN `##user_gedcom_setting` ugs ON (g.gedcom_id=ugs.gedcom_id AND ugs.setting_name='canedit' AND ugs.user_id=?)" .
300                " WHERE " .
301                "  g.gedcom_id>0 AND (" . // exclude the "template" tree
302                "    EXISTS (SELECT 1 FROM `##user_setting` WHERE user_id=? AND setting_name='canadmin' AND setting_value=1)" . // Admin sees all
303                "   ) OR (" .
304                "    (gs2.setting_value = 1 OR ugs.setting_value = 'admin') AND (" . // Allow imported trees, with either:
305                "     gs3.setting_value <> 1 OR" . // visitor access
306                "     IFNULL(ugs.setting_value, 'none')<>'none'" . // explicit access
307                "   )" .
308                "  )" .
309                " ORDER BY g.sort_order, 3"
310            )->execute([
311                Auth::id(),
312                Auth::id(),
313            ])->fetchAll();
314
315            foreach ($rows as $row) {
316                self::$trees[$row->tree_name] = new self((int) $row->tree_id, $row->tree_name, $row->tree_title);
317            }
318        }
319
320        return self::$trees;
321    }
322
323    /**
324     * Find the tree with a specific ID.
325     *
326     * @param int $tree_id
327     *
328     * @throws \DomainException
329     * @return Tree
330     */
331    public static function findById($tree_id): Tree
332    {
333        foreach (self::getAll() as $tree) {
334            if ($tree->tree_id == $tree_id) {
335                return $tree;
336            }
337        }
338        throw new \DomainException();
339    }
340
341    /**
342     * Find the tree with a specific name.
343     *
344     * @param string $tree_name
345     *
346     * @return Tree|null
347     */
348    public static function findByName($tree_name)
349    {
350        foreach (self::getAll() as $tree) {
351            if ($tree->name === $tree_name) {
352                return $tree;
353            }
354        }
355
356        return null;
357    }
358
359    /**
360     * Create arguments to select_edit_control()
361     * Note - these will be escaped later
362     *
363     * @return string[]
364     */
365    public static function getIdList(): array
366    {
367        $list = [];
368        foreach (self::getAll() as $tree) {
369            $list[$tree->tree_id] = $tree->title;
370        }
371
372        return $list;
373    }
374
375    /**
376     * Create arguments to select_edit_control()
377     * Note - these will be escaped later
378     *
379     * @return string[]
380     */
381    public static function getNameList(): array
382    {
383        $list = [];
384        foreach (self::getAll() as $tree) {
385            $list[$tree->name] = $tree->title;
386        }
387
388        return $list;
389    }
390
391    /**
392     * Create a new tree
393     *
394     * @param string $tree_name
395     * @param string $tree_title
396     *
397     * @return Tree
398     */
399    public static function create(string $tree_name, string $tree_title): Tree
400    {
401        try {
402            // Create a new tree
403            Database::prepare(
404                "INSERT INTO `##gedcom` (gedcom_name) VALUES (?)"
405            )->execute([$tree_name]);
406
407            $tree_id = (int) Database::prepare("SELECT LAST_INSERT_ID()")->fetchOne();
408        } catch (PDOException $ex) {
409            DebugBar::addThrowable($ex);
410
411            // A tree with that name already exists?
412            return self::findByName($tree_name);
413        }
414
415        // Update the list of trees - to include this new one
416        self::$trees = [];
417        $tree        = self::findById($tree_id);
418
419        $tree->setPreference('imported', '0');
420        $tree->setPreference('title', $tree_title);
421
422        // Module privacy
423        Module::setDefaultAccess($tree_id);
424
425        // Set preferences from default tree
426        Database::prepare(
427            "INSERT INTO `##gedcom_setting` (gedcom_id, setting_name, setting_value)" .
428            " SELECT :tree_id, setting_name, setting_value" .
429            " FROM `##gedcom_setting` WHERE gedcom_id = -1"
430        )->execute([
431            'tree_id' => $tree_id,
432        ]);
433
434        Database::prepare(
435            "INSERT INTO `##default_resn` (gedcom_id, tag_type, resn)" .
436            " SELECT :tree_id, tag_type, resn" .
437            " FROM `##default_resn` WHERE gedcom_id = -1"
438        )->execute([
439            'tree_id' => $tree_id,
440        ]);
441
442        Database::prepare(
443            "INSERT INTO `##block` (gedcom_id, location, block_order, module_name)" .
444            " SELECT :tree_id, location, block_order, module_name" .
445            " FROM `##block` WHERE gedcom_id = -1"
446        )->execute([
447            'tree_id' => $tree_id,
448        ]);
449
450        // Gedcom and privacy settings
451        $tree->setPreference('CONTACT_USER_ID', (string) Auth::id());
452        $tree->setPreference('WEBMASTER_USER_ID', (string) Auth::id());
453        $tree->setPreference('LANGUAGE', WT_LOCALE); // Default to the current admin’s language
454        switch (WT_LOCALE) {
455            case 'es':
456                $tree->setPreference('SURNAME_TRADITION', 'spanish');
457                break;
458            case 'is':
459                $tree->setPreference('SURNAME_TRADITION', 'icelandic');
460                break;
461            case 'lt':
462                $tree->setPreference('SURNAME_TRADITION', 'lithuanian');
463                break;
464            case 'pl':
465                $tree->setPreference('SURNAME_TRADITION', 'polish');
466                break;
467            case 'pt':
468            case 'pt-BR':
469                $tree->setPreference('SURNAME_TRADITION', 'portuguese');
470                break;
471            default:
472                $tree->setPreference('SURNAME_TRADITION', 'paternal');
473                break;
474        }
475
476        // Genealogy data
477        // It is simpler to create a temporary/unimported GEDCOM than to populate all the tables...
478        /* I18N: This should be a common/default/placeholder name of an individual. Put slashes around the surname. */
479        $john_doe = I18N::translate('John /DOE/');
480        $note     = I18N::translate('Edit this individual and replace their details with your own.');
481        Database::prepare("INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)")->execute([
482            $tree_id,
483            "0 HEAD\n1 CHAR UTF-8\n0 @I1@ INDI\n1 NAME {$john_doe}\n1 SEX M\n1 BIRT\n2 DATE 01 JAN 1850\n2 NOTE {$note}\n0 TRLR\n",
484        ]);
485
486        // Update our cache
487        self::$trees[$tree->tree_id] = $tree;
488
489        return $tree;
490    }
491
492    /**
493     * Are there any pending edits for this tree, than need reviewing by a moderator.
494     *
495     * @return bool
496     */
497    public function hasPendingEdit(): bool
498    {
499        return (bool) Database::prepare(
500            "SELECT 1 FROM `##change` WHERE status = 'pending' AND gedcom_id = :tree_id"
501        )->execute([
502            'tree_id' => $this->tree_id,
503        ])->fetchOne();
504    }
505
506    /**
507     * Delete all the genealogy data from a tree - in preparation for importing
508     * new data. Optionally retain the media data, for when the user has been
509     * editing their data offline using an application which deletes (or does not
510     * support) media data.
511     *
512     * @param bool $keep_media
513     *
514     * @return void
515     */
516    public function deleteGenealogyData(bool $keep_media)
517    {
518        Database::prepare("DELETE FROM `##gedcom_chunk` WHERE gedcom_id = ?")->execute([$this->tree_id]);
519        Database::prepare("DELETE FROM `##individuals`  WHERE i_file    = ?")->execute([$this->tree_id]);
520        Database::prepare("DELETE FROM `##families`     WHERE f_file    = ?")->execute([$this->tree_id]);
521        Database::prepare("DELETE FROM `##sources`      WHERE s_file    = ?")->execute([$this->tree_id]);
522        Database::prepare("DELETE FROM `##other`        WHERE o_file    = ?")->execute([$this->tree_id]);
523        Database::prepare("DELETE FROM `##places`       WHERE p_file    = ?")->execute([$this->tree_id]);
524        Database::prepare("DELETE FROM `##placelinks`   WHERE pl_file   = ?")->execute([$this->tree_id]);
525        Database::prepare("DELETE FROM `##name`         WHERE n_file    = ?")->execute([$this->tree_id]);
526        Database::prepare("DELETE FROM `##dates`        WHERE d_file    = ?")->execute([$this->tree_id]);
527        Database::prepare("DELETE FROM `##change`       WHERE gedcom_id = ?")->execute([$this->tree_id]);
528
529        if ($keep_media) {
530            Database::prepare("DELETE FROM `##link` WHERE l_file =? AND l_type<>'OBJE'")->execute([$this->tree_id]);
531        } else {
532            Database::prepare("DELETE FROM `##link`  WHERE l_file =?")->execute([$this->tree_id]);
533            Database::prepare("DELETE FROM `##media` WHERE m_file =?")->execute([$this->tree_id]);
534            Database::prepare("DELETE FROM `##media_file` WHERE m_file =?")->execute([$this->tree_id]);
535        }
536    }
537
538    /**
539     * Delete everything relating to a tree
540     *
541     * @return void
542     */
543    public function delete()
544    {
545        // If this is the default tree, then unset it
546        if (Site::getPreference('DEFAULT_GEDCOM') === $this->name) {
547            Site::setPreference('DEFAULT_GEDCOM', '');
548        }
549
550        $this->deleteGenealogyData(false);
551
552        Database::prepare("DELETE `##block_setting` FROM `##block_setting` JOIN `##block` USING (block_id) WHERE gedcom_id=?")->execute([$this->tree_id]);
553        Database::prepare("DELETE FROM `##block`               WHERE gedcom_id = ?")->execute([$this->tree_id]);
554        Database::prepare("DELETE FROM `##user_gedcom_setting` WHERE gedcom_id = ?")->execute([$this->tree_id]);
555        Database::prepare("DELETE FROM `##gedcom_setting`      WHERE gedcom_id = ?")->execute([$this->tree_id]);
556        Database::prepare("DELETE FROM `##module_privacy`      WHERE gedcom_id = ?")->execute([$this->tree_id]);
557        Database::prepare("DELETE FROM `##hit_counter`         WHERE gedcom_id = ?")->execute([$this->tree_id]);
558        Database::prepare("DELETE FROM `##default_resn`        WHERE gedcom_id = ?")->execute([$this->tree_id]);
559        Database::prepare("DELETE FROM `##gedcom_chunk`        WHERE gedcom_id = ?")->execute([$this->tree_id]);
560        Database::prepare("DELETE FROM `##log`                 WHERE gedcom_id = ?")->execute([$this->tree_id]);
561        Database::prepare("DELETE FROM `##gedcom`              WHERE gedcom_id = ?")->execute([$this->tree_id]);
562
563        // After updating the database, we need to fetch a new (sorted) copy
564        self::$trees = [];
565    }
566
567    /**
568     * Export the tree to a GEDCOM file
569     *
570     * @param resource $stream
571     *
572     * @return void
573     */
574    public function exportGedcom($stream)
575    {
576        $stmt = Database::prepare(
577            "SELECT i_gedcom AS gedcom, i_id AS xref, 1 AS n FROM `##individuals` WHERE i_file = :tree_id_1" .
578            " UNION ALL " .
579            "SELECT f_gedcom AS gedcom, f_id AS xref, 2 AS n FROM `##families`    WHERE f_file = :tree_id_2" .
580            " UNION ALL " .
581            "SELECT s_gedcom AS gedcom, s_id AS xref, 3 AS n FROM `##sources`     WHERE s_file = :tree_id_3" .
582            " UNION ALL " .
583            "SELECT o_gedcom AS gedcom, o_id AS xref, 4 AS n FROM `##other`       WHERE o_file = :tree_id_4 AND o_type NOT IN ('HEAD', 'TRLR')" .
584            " UNION ALL " .
585            "SELECT m_gedcom AS gedcom, m_id AS xref, 5 AS n FROM `##media`       WHERE m_file = :tree_id_5" .
586            " ORDER BY n, LENGTH(xref), xref"
587        )->execute([
588            'tree_id_1' => $this->tree_id,
589            'tree_id_2' => $this->tree_id,
590            'tree_id_3' => $this->tree_id,
591            'tree_id_4' => $this->tree_id,
592            'tree_id_5' => $this->tree_id,
593        ]);
594
595        $buffer = FunctionsExport::reformatRecord(FunctionsExport::gedcomHeader($this, 'UTF-8'));
596        while (($row = $stmt->fetch()) !== false) {
597            $buffer .= FunctionsExport::reformatRecord($row->gedcom);
598            if (strlen($buffer) > 65535) {
599                fwrite($stream, $buffer);
600                $buffer = '';
601            }
602        }
603        fwrite($stream, $buffer . '0 TRLR' . Gedcom::EOL);
604        $stmt->closeCursor();
605    }
606
607    /**
608     * Import data from a gedcom file into this tree.
609     *
610     * @param string $path     The full path to the (possibly temporary) file.
611     * @param string $filename The preferred filename, for export/download.
612     *
613     * @return void
614     * @throws Exception
615     */
616    public function importGedcomFile(string $path, string $filename)
617    {
618        // Read the file in blocks of roughly 64K. Ensure that each block
619        // contains complete gedcom records. This will ensure we don’t split
620        // multi-byte characters, as well as simplifying the code to import
621        // each block.
622
623        $file_data = '';
624        $fp        = fopen($path, 'rb');
625
626        if ($fp === false) {
627            throw new Exception('Cannot write file: ' . $path);
628        }
629
630        $this->deleteGenealogyData((bool) $this->getPreference('keep_media'));
631        $this->setPreference('gedcom_filename', $filename);
632        $this->setPreference('imported', '0');
633
634        while (!feof($fp)) {
635            $file_data .= fread($fp, 65536);
636            // There is no strrpos() function that searches for substrings :-(
637            for ($pos = strlen($file_data) - 1; $pos > 0; --$pos) {
638                if ($file_data[$pos] === '0' && ($file_data[$pos - 1] === "\n" || $file_data[$pos - 1] === "\r")) {
639                    // We’ve found the last record boundary in this chunk of data
640                    break;
641                }
642            }
643            if ($pos) {
644                Database::prepare(
645                    "INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)"
646                )->execute([
647                    $this->tree_id,
648                    substr($file_data, 0, $pos),
649                ]);
650                $file_data = substr($file_data, $pos);
651            }
652        }
653        Database::prepare(
654            "INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)"
655        )->execute([
656            $this->tree_id,
657            $file_data,
658        ]);
659
660        fclose($fp);
661    }
662
663    /**
664     * Generate a new XREF, unique across all family trees
665     *
666     * @return string
667     */
668    public function getNewXref(): string
669    {
670        $prefix = 'X';
671
672        $increment = 1.0;
673        do {
674            // Use LAST_INSERT_ID(expr) to provide a transaction-safe sequence. See
675            // http://dev.mysql.com/doc/refman/5.6/en/information-functions.html#function_last-insert-id
676            $statement = Database::prepare(
677                "UPDATE `##site_setting` SET setting_value = LAST_INSERT_ID(setting_value + :increment) WHERE setting_name = 'next_xref'"
678            );
679            $statement->execute([
680                'increment' => (int) $increment,
681            ]);
682
683            if ($statement->rowCount() === 0) {
684                $num = '1';
685                Site::setPreference('next_xref', $num);
686            } else {
687                $num = (string) Database::prepare("SELECT LAST_INSERT_ID()")->fetchOne();
688            }
689
690            $xref = $prefix . $num;
691
692            // Records may already exist with this sequence number.
693            $already_used = Database::prepare(
694                "SELECT" .
695                " EXISTS (SELECT 1 FROM `##individuals` WHERE i_id = :i_id) OR" .
696                " EXISTS (SELECT 1 FROM `##families` WHERE f_id = :f_id) OR" .
697                " EXISTS (SELECT 1 FROM `##sources` WHERE s_id = :s_id) OR" .
698                " EXISTS (SELECT 1 FROM `##media` WHERE m_id = :m_id) OR" .
699                " EXISTS (SELECT 1 FROM `##other` WHERE o_id = :o_id) OR" .
700                " EXISTS (SELECT 1 FROM `##change` WHERE xref = :xref)"
701            )->execute([
702                'i_id' => $xref,
703                'f_id' => $xref,
704                's_id' => $xref,
705                'm_id' => $xref,
706                'o_id' => $xref,
707                'xref' => $xref,
708            ])->fetchOne();
709
710            // This exponential increment allows us to scan over large blocks of
711            // existing data in a reasonable time.
712            $increment *= 1.01;
713        } while ($already_used !== '0');
714
715        return $xref;
716    }
717
718    /**
719     * Create a new record from GEDCOM data.
720     *
721     * @param string $gedcom
722     *
723     * @return GedcomRecord|Individual|Family|Note|Source|Repository|Media
724     * @throws InvalidArgumentException
725     */
726    public function createRecord(string $gedcom): GedcomRecord
727    {
728        if (substr_compare($gedcom, '0 @@', 0, 4) !== 0) {
729            throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@');
730        }
731
732        $xref   = $this->getNewXref();
733        $gedcom = '0 @' . $xref . '@' . substr($gedcom, 4);
734
735        // Create a change record
736        $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
737
738        // Create a pending change
739        Database::prepare(
740            "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, '', ?, ?)"
741        )->execute([
742            $this->tree_id,
743            $xref,
744            $gedcom,
745            Auth::id(),
746        ]);
747
748        // Accept this pending change
749        if (Auth::user()->getPreference('auto_accept')) {
750            FunctionsImport::acceptAllChanges($xref, $this);
751
752            return new GedcomRecord($xref, $gedcom, null, $this);
753        }
754
755
756        return new GedcomRecord($xref, '', $gedcom, $this);
757    }
758
759    /**
760     * Create a new family from GEDCOM data.
761     *
762     * @param string $gedcom
763     *
764     * @return Family
765     * @throws InvalidArgumentException
766     */
767    public function createFamily(string $gedcom): GedcomRecord
768    {
769        if (substr_compare($gedcom, '0 @@ FAM', 0, 8) !== 0) {
770            throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM');
771        }
772
773        $xref   = $this->getNewXref();
774        $gedcom = '0 @' . $xref . '@' . substr($gedcom, 4);
775
776        // Create a change record
777        $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
778
779        // Create a pending change
780        Database::prepare(
781            "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, '', ?, ?)"
782        )->execute([
783            $this->tree_id,
784            $xref,
785            $gedcom,
786            Auth::id(),
787        ]);
788
789        // Accept this pending change
790        if (Auth::user()->getPreference('auto_accept')) {
791            FunctionsImport::acceptAllChanges($xref, $this);
792
793            return new Family($xref, $gedcom, null, $this);
794        }
795
796        return new Family($xref, '', $gedcom, $this);
797    }
798
799    /**
800     * Create a new individual from GEDCOM data.
801     *
802     * @param string $gedcom
803     *
804     * @return Individual
805     * @throws InvalidArgumentException
806     */
807    public function createIndividual(string $gedcom): GedcomRecord
808    {
809        if (substr_compare($gedcom, '0 @@ INDI', 0, 9) !== 0) {
810            throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI');
811        }
812
813        $xref   = $this->getNewXref();
814        $gedcom = '0 @' . $xref . '@' . substr($gedcom, 4);
815
816        // Create a change record
817        $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
818
819        // Create a pending change
820        Database::prepare(
821            "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, '', ?, ?)"
822        )->execute([
823            $this->tree_id,
824            $xref,
825            $gedcom,
826            Auth::id(),
827        ]);
828
829        // Accept this pending change
830        if (Auth::user()->getPreference('auto_accept')) {
831            FunctionsImport::acceptAllChanges($xref, $this);
832
833            return new Individual($xref, $gedcom, null, $this);
834        }
835
836        return new Individual($xref, '', $gedcom, $this);
837    }
838
839    /**
840     * What is the most significant individual in this tree.
841     *
842     * @param User $user
843     *
844     * @return Individual
845     */
846    public function significantIndividual(User $user): Individual
847    {
848        static $individual; // Only query the DB once.
849
850        if (!$individual && $this->getUserPreference($user, 'rootid') !== '') {
851            $individual = Individual::getInstance($this->getUserPreference($user, 'rootid'), $this);
852        }
853        if (!$individual && $this->getUserPreference($user, 'gedcomid') !== '') {
854            $individual = Individual::getInstance($this->getUserPreference($user, 'gedcomid'), $this);
855        }
856        if (!$individual) {
857            $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this);
858        }
859        if (!$individual) {
860            $xref = (string) Database::prepare(
861                "SELECT MIN(i_id) FROM `##individuals` WHERE i_file = :tree_id"
862            )->execute([
863                'tree_id' => $this->getTreeId(),
864            ])->fetchOne();
865
866            $individual = Individual::getInstance($xref, $this);
867        }
868        if (!$individual) {
869            // always return a record
870            $individual = new Individual('I', '0 @I@ INDI', null, $this);
871        }
872
873        return $individual;
874    }
875
876    /**
877     * Get significant information from this page, to allow other pages such as
878     * charts and reports to initialise with the same records
879     *
880     * @return Individual
881     */
882    public function getSignificantIndividual(): Individual
883    {
884        static $individual; // Only query the DB once.
885
886        if (!$individual && $this->getUserPreference(Auth::user(), 'rootid') !== '') {
887            $individual = Individual::getInstance($this->getUserPreference(Auth::user(), 'rootid'), $this);
888        }
889        if (!$individual && $this->getUserPreference(Auth::user(), 'gedcomid') !== '') {
890            $individual = Individual::getInstance($this->getUserPreference(Auth::user(), 'gedcomid'), $this);
891        }
892        if (!$individual) {
893            $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this);
894        }
895        if (!$individual) {
896            $xref = (string) Database::prepare(
897                "SELECT MIN(i_id) FROM `##individuals` WHERE i_file = :tree_id"
898            )->execute([
899                'tree_id' => $this->getTreeId(),
900            ])->fetchOne();
901
902            $individual = Individual::getInstance($xref, $this);
903        }
904        if (!$individual) {
905            // always return a record
906            $individual = new Individual('I', '0 @I@ INDI', null, $this);
907        }
908
909        return $individual;
910    }
911}
912