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