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