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