xref: /webtrees/app/Tree.php (revision 771ae10a6b066c524545c3bc95fbde7ad19d076c)
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 Exception;
19use Fisharebest\Webtrees\Functions\FunctionsExport;
20use Fisharebest\Webtrees\Functions\FunctionsImport;
21use PDOException;
22
23/**
24 * Provide an interface to the wt_gedcom table.
25 */
26class Tree
27{
28    /** @var int The tree's ID number */
29    private $tree_id;
30
31    /** @var string The tree's name */
32    private $name;
33
34    /** @var string The tree's title */
35    private $title;
36
37    /** @var int[] Default access rules for facts in this tree */
38    private $fact_privacy;
39
40    /** @var int[] Default access rules for individuals in this tree */
41    private $individual_privacy;
42
43    /** @var integer[][] Default access rules for individual facts in this tree */
44    private $individual_fact_privacy;
45
46    /** @var Tree[] All trees that we have permission to see. */
47    private static $trees = [];
48
49    /** @var string[] Cached copy of the wt_gedcom_setting table. */
50    private $preferences = [];
51
52    /** @var string[][] Cached copy of the wt_user_gedcom_setting table. */
53    private $user_preferences = [];
54
55    /**
56     * Create a tree object. This is a private constructor - it can only
57     * be called from Tree::getAll() to ensure proper initialisation.
58     *
59     * @param int    $tree_id
60     * @param string $tree_name
61     * @param string $tree_title
62     */
63    private function __construct($tree_id, $tree_name, $tree_title)
64    {
65        $this->tree_id                 = $tree_id;
66        $this->name                    = $tree_name;
67        $this->title                   = $tree_title;
68        $this->fact_privacy            = [];
69        $this->individual_privacy      = [];
70        $this->individual_fact_privacy = [];
71
72        // Load the privacy settings for this tree
73        $rows = Database::prepare(
74            "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" .
75            " FROM `##default_resn` WHERE gedcom_id = :tree_id"
76        )->execute([
77            'priv_public' => Auth::PRIV_PRIVATE,
78            'priv_user'   => Auth::PRIV_USER,
79            'priv_none'   => Auth::PRIV_NONE,
80            'priv_hide'   => Auth::PRIV_HIDE,
81            'tree_id'     => $this->tree_id,
82        ])->fetchAll();
83
84        foreach ($rows as $row) {
85            if ($row->xref !== null) {
86                if ($row->tag_type !== null) {
87                    $this->individual_fact_privacy[$row->xref][$row->tag_type] = (int) $row->resn;
88                } else {
89                    $this->individual_privacy[$row->xref] = (int) $row->resn;
90                }
91            } else {
92                $this->fact_privacy[$row->tag_type] = (int) $row->resn;
93            }
94        }
95    }
96
97    /**
98     * The ID of this tree
99     *
100     * @return int
101     */
102    public function getTreeId()
103    {
104        return $this->tree_id;
105    }
106
107    /**
108     * The name of this tree
109     *
110     * @return string
111     */
112    public function getName()
113    {
114        return $this->name;
115    }
116
117    /**
118     * The title of this tree
119     *
120     * @return string
121     */
122    public function getTitle()
123    {
124        return $this->title;
125    }
126
127    /**
128     * The fact-level privacy for this tree.
129     *
130     * @return int[]
131     */
132    public function getFactPrivacy()
133    {
134        return $this->fact_privacy;
135    }
136
137    /**
138     * The individual-level privacy for this tree.
139     *
140     * @return int[]
141     */
142    public function getIndividualPrivacy()
143    {
144        return $this->individual_privacy;
145    }
146
147    /**
148     * The individual-fact-level privacy for this tree.
149     *
150     * @return int[][]
151     */
152    public function getIndividualFactPrivacy()
153    {
154        return $this->individual_fact_privacy;
155    }
156
157    /**
158     * Get the tree’s configuration settings.
159     *
160     * @param string $setting_name
161     * @param string $default
162     *
163     * @return string
164     */
165    public function getPreference(string $setting_name, string $default = ''): string
166    {
167        if (empty($this->preferences)) {
168            $this->preferences = Database::prepare(
169                "SELECT setting_name, setting_value FROM `##gedcom_setting` WHERE gedcom_id = ?"
170            )->execute([$this->tree_id])->fetchAssoc();
171        }
172
173        if (array_key_exists($setting_name, $this->preferences)) {
174            return $this->preferences[$setting_name];
175        } else {
176            return $default;
177        }
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        if (array_key_exists($setting_name, $this->user_preferences[$user->getUserId()])) {
231            return $this->user_preferences[$user->getUserId()][$setting_name];
232        } else {
233            return $default;
234        }
235    }
236
237    /**
238     * Set the tree’s user-configuration settings.
239     *
240     * @param User   $user
241     * @param string $setting_name
242     * @param string $setting_value
243     *
244     * @return $this
245     */
246    public function setUserPreference(User $user, string $setting_name, string $setting_value): Tree
247    {
248        if ($this->getUserPreference($user, $setting_name) !== $setting_value) {
249            // Update the database
250            if ($setting_value === '') {
251                Database::prepare(
252                    "DELETE FROM `##user_gedcom_setting` WHERE gedcom_id = :tree_id AND user_id = :user_id AND setting_name = :setting_name"
253                )->execute([
254                    'tree_id'      => $this->tree_id,
255                    'user_id'      => $user->getUserId(),
256                    'setting_name' => $setting_name,
257                ]);
258            } else {
259                Database::prepare(
260                    "REPLACE INTO `##user_gedcom_setting` (user_id, gedcom_id, setting_name, setting_value) VALUES (:user_id, :tree_id, :setting_name, LEFT(:setting_value, 255))"
261                )->execute([
262                    'user_id'       => $user->getUserId(),
263                    'tree_id'       => $this->tree_id,
264                    'setting_name'  => $setting_name,
265                    'setting_value' => $setting_value,
266                ]);
267            }
268            // Update our cache
269            $this->user_preferences[$user->getUserId()][$setting_name] = $setting_value;
270            // Audit log of changes
271            Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->getUserName() . '"', $this);
272        }
273
274        return $this;
275    }
276
277    /**
278     * Can a user accept changes for this tree?
279     *
280     * @param User $user
281     *
282     * @return bool
283     */
284    public function canAcceptChanges(User $user): bool
285    {
286        return Auth::isModerator($this, $user);
287    }
288
289    /**
290     * Fetch all the trees that we have permission to access.
291     *
292     * @return Tree[]
293     */
294    public static function getAll(): array
295    {
296        if (empty(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
319            foreach ($rows as $row) {
320                self::$trees[$row->tree_name] = new self((int) $row->tree_id, $row->tree_name, $row->tree_title);
321            }
322        }
323
324        return self::$trees;
325    }
326
327    /**
328     * Find the tree with a specific ID.
329     *
330     * @param int $tree_id
331     *
332     * @throws \DomainException
333     * @return Tree
334     */
335    public static function findById($tree_id): Tree
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(): array
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(): array
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(string $tree_name, string $tree_title): Tree
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 = [];
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        /* I18N: This should be a common/default/placeholder name of an individual. Put slashes around the surname. */
482        $john_doe = 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(): bool
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     * @return void
518     */
519    public function deleteGenealogyData(bool $keep_media)
520    {
521        Database::prepare("DELETE FROM `##gedcom_chunk` WHERE gedcom_id = ?")->execute([$this->tree_id]);
522        Database::prepare("DELETE FROM `##individuals`  WHERE i_file    = ?")->execute([$this->tree_id]);
523        Database::prepare("DELETE FROM `##families`     WHERE f_file    = ?")->execute([$this->tree_id]);
524        Database::prepare("DELETE FROM `##sources`      WHERE s_file    = ?")->execute([$this->tree_id]);
525        Database::prepare("DELETE FROM `##other`        WHERE o_file    = ?")->execute([$this->tree_id]);
526        Database::prepare("DELETE FROM `##places`       WHERE p_file    = ?")->execute([$this->tree_id]);
527        Database::prepare("DELETE FROM `##placelinks`   WHERE pl_file   = ?")->execute([$this->tree_id]);
528        Database::prepare("DELETE FROM `##name`         WHERE n_file    = ?")->execute([$this->tree_id]);
529        Database::prepare("DELETE FROM `##dates`        WHERE d_file    = ?")->execute([$this->tree_id]);
530        Database::prepare("DELETE FROM `##change`       WHERE gedcom_id = ?")->execute([$this->tree_id]);
531
532        if ($keep_media) {
533            Database::prepare("DELETE FROM `##link` WHERE l_file =? AND l_type<>'OBJE'")->execute([$this->tree_id]);
534        } else {
535            Database::prepare("DELETE FROM `##link`  WHERE l_file =?")->execute([$this->tree_id]);
536            Database::prepare("DELETE FROM `##media` WHERE m_file =?")->execute([$this->tree_id]);
537            Database::prepare("DELETE FROM `##media_file` WHERE m_file =?")->execute([$this->tree_id]);
538        }
539    }
540
541    /**
542     * Delete everything relating to a tree
543     *
544     * @return void
545     */
546    public function delete()
547    {
548        // If this is the default tree, then unset it
549        if (Site::getPreference('DEFAULT_GEDCOM') === $this->name) {
550            Site::setPreference('DEFAULT_GEDCOM', '');
551        }
552
553        $this->deleteGenealogyData(false);
554
555        Database::prepare("DELETE `##block_setting` FROM `##block_setting` JOIN `##block` USING (block_id) WHERE gedcom_id=?")->execute([$this->tree_id]);
556        Database::prepare("DELETE FROM `##block`               WHERE gedcom_id = ?")->execute([$this->tree_id]);
557        Database::prepare("DELETE FROM `##user_gedcom_setting` WHERE gedcom_id = ?")->execute([$this->tree_id]);
558        Database::prepare("DELETE FROM `##gedcom_setting`      WHERE gedcom_id = ?")->execute([$this->tree_id]);
559        Database::prepare("DELETE FROM `##module_privacy`      WHERE gedcom_id = ?")->execute([$this->tree_id]);
560        Database::prepare("DELETE FROM `##hit_counter`         WHERE gedcom_id = ?")->execute([$this->tree_id]);
561        Database::prepare("DELETE FROM `##default_resn`        WHERE gedcom_id = ?")->execute([$this->tree_id]);
562        Database::prepare("DELETE FROM `##gedcom_chunk`        WHERE gedcom_id = ?")->execute([$this->tree_id]);
563        Database::prepare("DELETE FROM `##log`                 WHERE gedcom_id = ?")->execute([$this->tree_id]);
564        Database::prepare("DELETE FROM `##gedcom`              WHERE gedcom_id = ?")->execute([$this->tree_id]);
565
566        // After updating the database, we need to fetch a new (sorted) copy
567        self::$trees = [];
568    }
569
570    /**
571     * Export the tree to a GEDCOM file
572     *
573     * @param resource $stream
574     *
575     * @return void
576     */
577    public function exportGedcom($stream)
578    {
579        $stmt = Database::prepare(
580            "SELECT i_gedcom AS gedcom, i_id AS xref, 1 AS n FROM `##individuals` WHERE i_file = :tree_id_1" .
581            " UNION ALL " .
582            "SELECT f_gedcom AS gedcom, f_id AS xref, 2 AS n FROM `##families`    WHERE f_file = :tree_id_2" .
583            " UNION ALL " .
584            "SELECT s_gedcom AS gedcom, s_id AS xref, 3 AS n FROM `##sources`     WHERE s_file = :tree_id_3" .
585            " UNION ALL " .
586            "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')" .
587            " UNION ALL " .
588            "SELECT m_gedcom AS gedcom, m_id AS xref, 5 AS n FROM `##media`       WHERE m_file = :tree_id_5" .
589            " ORDER BY n, LENGTH(xref), xref"
590        )->execute([
591            'tree_id_1' => $this->tree_id,
592            'tree_id_2' => $this->tree_id,
593            'tree_id_3' => $this->tree_id,
594            'tree_id_4' => $this->tree_id,
595            'tree_id_5' => $this->tree_id,
596        ]);
597
598        $buffer = FunctionsExport::reformatRecord(FunctionsExport::gedcomHeader($this));
599        while (($row = $stmt->fetch()) !== false) {
600            $buffer .= FunctionsExport::reformatRecord($row->gedcom);
601            if (strlen($buffer) > 65535) {
602                fwrite($stream, $buffer);
603                $buffer = '';
604            }
605        }
606        fwrite($stream, $buffer . '0 TRLR' . Gedcom::EOL);
607        $stmt->closeCursor();
608    }
609
610    /**
611     * Import data from a gedcom file into this tree.
612     *
613     * @param string $path     The full path to the (possibly temporary) file.
614     * @param string $filename The preferred filename, for export/download.
615     *
616     * @return void
617     * @throws Exception
618     */
619    public function importGedcomFile(string $path, string $filename)
620    {
621        // Read the file in blocks of roughly 64K. Ensure that each block
622        // contains complete gedcom records. This will ensure we don’t split
623        // multi-byte characters, as well as simplifying the code to import
624        // each block.
625
626        $file_data = '';
627        $fp        = fopen($path, 'rb');
628
629        // Don’t allow the user to cancel the request. We do not want to be left with an incomplete transaction.
630        ignore_user_abort(true);
631
632        $this->deleteGenealogyData((bool) $this->getPreference('keep_media'));
633        $this->setPreference('gedcom_filename', $filename);
634        $this->setPreference('imported', '0');
635
636        while (!feof($fp)) {
637            $file_data .= fread($fp, 65536);
638            // There is no strrpos() function that searches for substrings :-(
639            for ($pos = strlen($file_data) - 1; $pos > 0; --$pos) {
640                if ($file_data[$pos] === '0' && ($file_data[$pos - 1] === "\n" || $file_data[$pos - 1] === "\r")) {
641                    // We’ve found the last record boundary in this chunk of data
642                    break;
643                }
644            }
645            if ($pos) {
646                Database::prepare(
647                    "INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)"
648                )->execute([
649                    $this->tree_id,
650                    substr($file_data, 0, $pos),
651                ]);
652                $file_data = substr($file_data, $pos);
653            }
654        }
655        Database::prepare(
656            "INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)"
657        )->execute([
658            $this->tree_id,
659            $file_data,
660        ]);
661
662        fclose($fp);
663    }
664
665    /**
666     * Generate a new XREF, unique across all family trees
667     *
668     * @return string
669     */
670    public function getNewXref(): string
671    {
672        $prefix = 'X';
673
674        $increment = 1.0;
675        do {
676            // Use LAST_INSERT_ID(expr) to provide a transaction-safe sequence. See
677            // http://dev.mysql.com/doc/refman/5.6/en/information-functions.html#function_last-insert-id
678            $statement = Database::prepare(
679                "UPDATE `##site_setting` SET setting_value = LAST_INSERT_ID(setting_value + :increment) WHERE setting_name = 'next_xref'"
680            );
681            $statement->execute([
682                'increment' => (int) $increment,
683            ]);
684
685            if ($statement->rowCount() === 0) {
686                // First time we've used this record type.
687                Site::setPreference('next_xref', '1');
688                $num = 1;
689            } else {
690                $num = Database::prepare("SELECT LAST_INSERT_ID()")->fetchOne();
691            }
692
693            $xref = $prefix . $num;
694
695            // Records may already exist with this sequence number.
696            $already_used = Database::prepare(
697                "SELECT" .
698                " EXISTS (SELECT 1 FROM `##individuals` WHERE i_id = :i_id) OR" .
699                " EXISTS (SELECT 1 FROM `##families` WHERE f_id = :f_id) OR" .
700                " EXISTS (SELECT 1 FROM `##sources` WHERE s_id = :s_id) OR" .
701                " EXISTS (SELECT 1 FROM `##media` WHERE m_id = :m_id) OR" .
702                " EXISTS (SELECT 1 FROM `##other` WHERE o_id = :o_id) OR" .
703                " EXISTS (SELECT 1 FROM `##change` WHERE xref = :xref)"
704            )->execute([
705                'i_id' => $xref,
706                'f_id' => $xref,
707                's_id' => $xref,
708                'm_id' => $xref,
709                'o_id' => $xref,
710                'xref' => $xref,
711            ])->fetchOne();
712
713            // This exponential increment allows us to scan over large blocks of
714            // existing data in a reasonable time.
715            $increment *= 1.01;
716        } while ($already_used !== '0');
717
718        return $xref;
719    }
720
721    /**
722     * Create a new record from GEDCOM data.
723     *
724     * @param string $gedcom
725     *
726     * @return GedcomRecord|Individual|Family|Note|Source|Repository|Media
727     * @throws Exception
728     */
729    public function createRecord(string $gedcom): GedcomRecord
730    {
731        if (preg_match('/^0 @(' . WT_REGEX_XREF . ')@ (' . WT_REGEX_TAG . ')/', $gedcom, $match)) {
732            $xref = $match[1];
733            $type = $match[2];
734        } else {
735            throw new Exception('Invalid argument to GedcomRecord::createRecord(' . $gedcom . ')');
736        }
737        if (strpos($gedcom, "\r") !== false) {
738            // MSDOS line endings will break things in horrible ways
739            throw new Exception('Evil line endings found in GedcomRecord::createRecord(' . $gedcom . ')');
740        }
741
742        // webtrees creates XREFs containing digits. Anything else (e.g. “new”) is just a placeholder.
743        if (!preg_match('/\d/', $xref)) {
744            $xref   = $this->getNewXref();
745            $gedcom = preg_replace('/^0 @(' . WT_REGEX_XREF . ')@/', '0 @' . $xref . '@', $gedcom);
746        }
747
748        // Create a change record, if not already present
749        if (!preg_match('/\n1 CHAN/', $gedcom)) {
750            $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
751        }
752
753        // Create a pending change
754        Database::prepare(
755            "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, '', ?, ?)"
756        )->execute([
757            $this->tree_id,
758            $xref,
759            $gedcom,
760            Auth::id(),
761        ]);
762
763        Log::addEditLog('Create: ' . $type . ' ' . $xref, $this);
764
765        // Accept this pending change
766        if (Auth::user()->getPreference('auto_accept')) {
767            FunctionsImport::acceptAllChanges($xref, $this);
768        }
769        // Return the newly created record. Note that since GedcomRecord
770        // has a cache of pending changes, we cannot use it to create a
771        // record with a newly created pending change.
772        return GedcomRecord::getInstance($xref, $this, $gedcom);
773    }
774
775    /**
776     * What is the most significant individual in this tree.
777     *
778     * @param User $user
779     *
780     * @return Individual
781     */
782    public function significantIndividual(User $user): Individual
783    {
784        static $individual; // Only query the DB once.
785
786        if (!$individual && $this->getUserPreference($user, 'rootid') !== '') {
787            $individual = Individual::getInstance($this->getUserPreference($user, 'rootid'), $this);
788        }
789        if (!$individual && $this->getUserPreference($user, 'gedcomid') !== '') {
790            $individual = Individual::getInstance($this->getUserPreference($user, 'gedcomid'), $this);
791        }
792        if (!$individual) {
793            $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this);
794        }
795        if (!$individual) {
796            $individual = Individual::getInstance(
797                Database::prepare(
798                    "SELECT MIN(i_id) FROM `##individuals` WHERE i_file = :tree_id"
799                )->execute([
800                    'tree_id' => $this->getTreeId(),
801                ])->fetchOne(),
802                $this
803            );
804        }
805        if (!$individual) {
806            // always return a record
807            $individual = new Individual('I', '0 @I@ INDI', null, $this);
808        }
809
810        return $individual;
811    }
812
813    /**
814     * Get significant information from this page, to allow other pages such as
815     * charts and reports to initialise with the same records
816     *
817     * @return Individual
818     */
819    public function getSignificantIndividual(): Individual
820    {
821        static $individual; // Only query the DB once.
822
823        if (!$individual && $this->getUserPreference(Auth::user(), 'rootid') !== '') {
824            $individual = Individual::getInstance($this->getUserPreference(Auth::user(), 'rootid'), $this);
825        }
826        if (!$individual && $this->getUserPreference(Auth::user(), 'gedcomid') !== '') {
827            $individual = Individual::getInstance($this->getUserPreference(Auth::user(), 'gedcomid'), $this);
828        }
829        if (!$individual) {
830            $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this);
831        }
832        if (!$individual) {
833            $individual = Individual::getInstance(
834                Database::prepare(
835                    "SELECT MIN(i_id) FROM `##individuals` WHERE i_file = ?"
836                )->execute([$this->getTreeId()])->fetchOne(),
837                $this
838            );
839        }
840        if (!$individual) {
841            // always return a record
842            $individual = new Individual('I', '0 @I@ INDI', null, $this);
843        }
844
845        return $individual;
846    }
847}
848