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