Completed
Push — openstreetmap ( c02ae5...190f01 )
by Greg
17:40 queued 08:16
created

Tree::findById()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 3
nop 1
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
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
 */
16
namespace Fisharebest\Webtrees;
17
18
use Fisharebest\Webtrees\Functions\FunctionsExport;
19
use Fisharebest\Webtrees\Functions\FunctionsImport;
20
use Fisharebest\Webtrees\Gedcom;
21
use PDOException;
22
23
/**
24
 * Provide an interface to the wt_gedcom table.
25
 */
26
class Tree {
27
	/** @var int The tree's ID number */
28
	private $tree_id;
29
30
	/** @var string The tree's name */
31
	private $name;
32
33
	/** @var string The tree's title */
34
	private $title;
35
36
	/** @var int[] Default access rules for facts in this tree */
37
	private $fact_privacy;
38
39
	/** @var int[] Default access rules for individuals in this tree */
40
	private $individual_privacy;
41
42
	/** @var integer[][] Default access rules for individual facts in this tree */
43
	private $individual_fact_privacy;
44
45
	/** @var Tree[] All trees that we have permission to see. */
46
	private static $trees;
47
48
	/** @var string[] Cached copy of the wt_gedcom_setting table. */
49
	private $preferences = [];
50
51
	/** @var string[][] Cached copy of the wt_user_gedcom_setting table. */
52
	private $user_preferences = [];
53
54
	/**
55
	 * Create a tree object. This is a private constructor - it can only
56
	 * be called from Tree::getAll() to ensure proper initialisation.
57
	 *
58
	 * @param int    $tree_id
59
	 * @param string $tree_name
60
	 * @param string $tree_title
61
	 */
62
	private function __construct($tree_id, $tree_name, $tree_title) {
63
		$this->tree_id                 = $tree_id;
64
		$this->name                    = $tree_name;
65
		$this->title                   = $tree_title;
66
		$this->fact_privacy            = [];
67
		$this->individual_privacy      = [];
68
		$this->individual_fact_privacy = [];
69
70
		// Load the privacy settings for this tree
71
		$rows = Database::prepare(
72
			"SELECT SQL_CACHE 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" .
73
			" FROM `##default_resn` WHERE gedcom_id = :tree_id"
74
		)->execute([
75
			'priv_public' => Auth::PRIV_PRIVATE,
76
			'priv_user'   => Auth::PRIV_USER,
77
			'priv_none'   => Auth::PRIV_NONE,
78
			'priv_hide'   => Auth::PRIV_HIDE,
79
			'tree_id'     => $this->tree_id,
80
		])->fetchAll();
81
82
		foreach ($rows as $row) {
83
			if ($row->xref !== null) {
84
				if ($row->tag_type !== null) {
85
					$this->individual_fact_privacy[$row->xref][$row->tag_type] = (int) $row->resn;
86
				} else {
87
					$this->individual_privacy[$row->xref] = (int) $row->resn;
88
				}
89
			} else {
90
				$this->fact_privacy[$row->tag_type] = (int) $row->resn;
91
			}
92
		}
93
	}
94
95
	/**
96
	 * The ID of this tree
97
	 *
98
	 * @return int
99
	 */
100
	public function getTreeId() {
101
		return $this->tree_id;
102
	}
103
104
	/**
105
	 * The name of this tree
106
	 *
107
	 * @return string
108
	 */
109
	public function getName() {
110
		return $this->name;
111
	}
112
113
	/**
114
	 * The name of this tree
115
	 *
116
	 * @return string
117
	 */
118
	public function getNameUrl() {
119
		return rawurlencode($this->name);
120
	}
121
122
	/**
123
	 * The title of this tree
124
	 *
125
	 * @return string
126
	 */
127
	public function getTitle() {
128
		return $this->title;
129
	}
130
131
	/**
132
	 * The fact-level privacy for this tree.
133
	 *
134
	 * @return int[]
135
	 */
136
	public function getFactPrivacy() {
137
		return $this->fact_privacy;
138
	}
139
140
	/**
141
	 * The individual-level privacy for this tree.
142
	 *
143
	 * @return int[]
144
	 */
145
	public function getIndividualPrivacy() {
146
		return $this->individual_privacy;
147
	}
148
149
	/**
150
	 * The individual-fact-level privacy for this tree.
151
	 *
152
	 * @return int[][]
153
	 */
154
	public function getIndividualFactPrivacy() {
155
		return $this->individual_fact_privacy;
156
	}
157
158
	/**
159
	 * Get the tree’s configuration settings.
160
	 *
161
	 * @param string $setting_name
162
	 * @param string $default
163
	 *
164
	 * @return string
165
	 */
166
	public function getPreference($setting_name, $default = '') {
167
		if (empty($this->preferences)) {
168
			$this->preferences = Database::prepare(
169
				"SELECT SQL_CACHE setting_name, setting_value FROM `##gedcom_setting` WHERE gedcom_id = ?"
170
			)->execute([$this->tree_id])->fetchAssoc();
171
		}
172
173
		if (array_key_exists($setting_name, $this->preferences)) {
174
			return $this->preferences[$setting_name];
175
		} else {
176
			return $default;
177
		}
178
	}
179
180
	/**
181
	 * Set the tree’s configuration settings.
182
	 *
183
	 * @param string $setting_name
184
	 * @param string $setting_value
185
	 *
186
	 * @return $this
187
	 */
188
	public function setPreference($setting_name, $setting_value) {
189
		if ($setting_value !== $this->getPreference($setting_name)) {
190
			Database::prepare(
191
				"REPLACE INTO `##gedcom_setting` (gedcom_id, setting_name, setting_value)" .
192
				" VALUES (:tree_id, :setting_name, LEFT(:setting_value, 255))"
193
			)->execute([
194
				'tree_id'       => $this->tree_id,
195
				'setting_name'  => $setting_name,
196
				'setting_value' => $setting_value,
197
			]);
198
199
			$this->preferences[$setting_name] = $setting_value;
200
201
			Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this);
202
		}
203
204
		return $this;
205
	}
206
207
	/**
208
	 * Get the tree’s user-configuration settings.
209
	 *
210
	 * @param User        $user
211
	 * @param string      $setting_name
212
	 * @param string|null $default
213
	 *
214
	 * @return string
215
	 */
216
	public function getUserPreference(User $user, $setting_name, $default = null) {
217
		// There are lots of settings, and we need to fetch lots of them on every page
218
		// so it is quicker to fetch them all in one go.
219
		if (!array_key_exists($user->getUserId(), $this->user_preferences)) {
220
			$this->user_preferences[$user->getUserId()] = Database::prepare(
221
				"SELECT SQL_CACHE setting_name, setting_value FROM `##user_gedcom_setting` WHERE user_id = ? AND gedcom_id = ?"
222
			)->execute([$user->getUserId(), $this->tree_id])->fetchAssoc();
223
		}
224
225
		if (array_key_exists($setting_name, $this->user_preferences[$user->getUserId()])) {
226
			return $this->user_preferences[$user->getUserId()][$setting_name];
227
		} else {
228
			return $default;
229
		}
230
	}
231
232
	/**
233
	 * Set the tree’s user-configuration settings.
234
	 *
235
	 * @param User    $user
236
	 * @param string  $setting_name
237
	 * @param string  $setting_value
238
	 *
239
	 * @return $this
240
	 */
241
	public function setUserPreference(User $user, $setting_name, $setting_value) {
242
		if ($this->getUserPreference($user, $setting_name) !== $setting_value) {
243
			// Update the database
244
			if ($setting_value === null) {
0 ignored issues
show
introduced by
The condition $setting_value === null is always false.
Loading history...
245
				Database::prepare(
246
					"DELETE FROM `##user_gedcom_setting` WHERE gedcom_id = :tree_id AND user_id = :user_id AND setting_name = :setting_name"
247
				)->execute([
248
					'tree_id'      => $this->tree_id,
249
					'user_id'      => $user->getUserId(),
250
					'setting_name' => $setting_name,
251
				]);
252
			} else {
253
				Database::prepare(
254
					"REPLACE INTO `##user_gedcom_setting` (user_id, gedcom_id, setting_name, setting_value) VALUES (:user_id, :tree_id, :setting_name, LEFT(:setting_value, 255))"
255
				)->execute([
256
					'user_id'       => $user->getUserId(),
257
					'tree_id'       => $this->tree_id,
258
					'setting_name'  => $setting_name,
259
					'setting_value' => $setting_value,
260
				]);
261
			}
262
			// Update our cache
263
			$this->user_preferences[$user->getUserId()][$setting_name] = $setting_value;
264
			// Audit log of changes
265
			Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->getUserName() . '"', $this);
266
		}
267
268
		return $this;
269
	}
270
271
	/**
272
	 * Can a user accept changes for this tree?
273
	 *
274
	 * @param User $user
275
	 *
276
	 * @return bool
277
	 */
278
	public function canAcceptChanges(User $user) {
279
		return Auth::isModerator($this, $user);
280
	}
281
282
	/**
283
	 * Fetch all the trees that we have permission to access.
284
	 *
285
	 * @return Tree[]
286
	 */
287
	public static function getAll() {
288
		if (self::$trees === null) {
0 ignored issues
show
introduced by
The condition self::trees === null is always false. If self::trees === null can have other possible types, add them to app/Tree.php:45
Loading history...
289
			self::$trees = [];
290
			$rows        = Database::prepare(
291
				"SELECT SQL_CACHE g.gedcom_id AS tree_id, g.gedcom_name AS tree_name, gs1.setting_value AS tree_title" .
292
				" FROM `##gedcom` g" .
293
				" LEFT JOIN `##gedcom_setting`      gs1 ON (g.gedcom_id=gs1.gedcom_id AND gs1.setting_name='title')" .
294
				" LEFT JOIN `##gedcom_setting`      gs2 ON (g.gedcom_id=gs2.gedcom_id AND gs2.setting_name='imported')" .
295
				" LEFT JOIN `##gedcom_setting`      gs3 ON (g.gedcom_id=gs3.gedcom_id AND gs3.setting_name='REQUIRE_AUTHENTICATION')" .
296
				" LEFT JOIN `##user_gedcom_setting` ugs ON (g.gedcom_id=ugs.gedcom_id AND ugs.setting_name='canedit' AND ugs.user_id=?)" .
297
				" WHERE " .
298
				"  g.gedcom_id>0 AND (" . // exclude the "template" tree
299
				"    EXISTS (SELECT 1 FROM `##user_setting` WHERE user_id=? AND setting_name='canadmin' AND setting_value=1)" . // Admin sees all
300
				"   ) OR (" .
301
				"    (gs2.setting_value = 1 OR ugs.setting_value = 'admin') AND (" . // Allow imported trees, with either:
302
				"     gs3.setting_value <> 1 OR" . // visitor access
303
				"     IFNULL(ugs.setting_value, 'none')<>'none'" . // explicit access
304
				"   )" .
305
				"  )" .
306
				" ORDER BY g.sort_order, 3"
307
			)->execute([Auth::id(), Auth::id()])->fetchAll();
308
			foreach ($rows as $row) {
309
				self::$trees[$row->tree_name] = new self((int) $row->tree_id, $row->tree_name, $row->tree_title);
310
			}
311
		}
312
313
		return self::$trees;
314
	}
315
316
	/**
317
	 * Find the tree with a specific ID.
318
	 *
319
	 * @param int $tree_id
320
	 *
321
	 * @throws \DomainException
322
	 *
323
	 * @return Tree
324
	 */
325
	public static function findById($tree_id) {
326
		foreach (self::getAll() as $tree) {
327
			if ($tree->tree_id == $tree_id) {
328
				return $tree;
329
			}
330
		}
331
		throw new \DomainException;
332
	}
333
334
	/**
335
	 * Find the tree with a specific name.
336
	 *
337
	 * @param string $tree_name
338
	 *
339
	 * @return Tree|null
340
	 */
341
	public static function findByName($tree_name) {
342
		foreach (self::getAll() as $tree) {
343
			if ($tree->name === $tree_name) {
344
				return $tree;
345
			}
346
		}
347
348
		return null;
349
	}
350
351
	/**
352
	 * Create arguments to select_edit_control()
353
	 * Note - these will be escaped later
354
	 *
355
	 * @return string[]
356
	 */
357
	public static function getIdList() {
358
		$list = [];
359
		foreach (self::getAll() as $tree) {
360
			$list[$tree->tree_id] = $tree->title;
361
		}
362
363
		return $list;
364
	}
365
366
	/**
367
	 * Create arguments to select_edit_control()
368
	 * Note - these will be escaped later
369
	 *
370
	 * @return string[]
371
	 */
372
	public static function getNameList() {
373
		$list = [];
374
		foreach (self::getAll() as $tree) {
375
			$list[$tree->name] = $tree->title;
376
		}
377
378
		return $list;
379
	}
380
381
	/**
382
	 * Create a new tree
383
	 *
384
	 * @param string $tree_name
385
	 * @param string $tree_title
386
	 *
387
	 * @return Tree
388
	 */
389
	public static function create($tree_name, $tree_title) {
390
		try {
391
			// Create a new tree
392
			Database::prepare(
393
				"INSERT INTO `##gedcom` (gedcom_name) VALUES (?)"
394
			)->execute([$tree_name]);
395
			$tree_id = Database::prepare("SELECT LAST_INSERT_ID()")->fetchOne();
396
		} catch (PDOException $ex) {
397
			DebugBar::addThrowable($ex);
398
399
			// A tree with that name already exists?
400
			return self::findByName($tree_name);
401
		}
402
403
		// Update the list of trees - to include this new one
404
		self::$trees = null;
405
		$tree        = self::findById($tree_id);
0 ignored issues
show
Bug introduced by
It seems like $tree_id can also be of type string; however, parameter $tree_id of Fisharebest\Webtrees\Tree::findById() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

405
		$tree        = self::findById(/** @scrutinizer ignore-type */ $tree_id);
Loading history...
406
407
		$tree->setPreference('imported', '0');
408
		$tree->setPreference('title', $tree_title);
409
410
		// Module privacy
411
		Module::setDefaultAccess($tree_id);
0 ignored issues
show
Bug introduced by
It seems like $tree_id can also be of type string; however, parameter $tree_id of Fisharebest\Webtrees\Module::setDefaultAccess() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

411
		Module::setDefaultAccess(/** @scrutinizer ignore-type */ $tree_id);
Loading history...
412
413
		// Set preferences from default tree
414
		Database::prepare(
415
			"INSERT INTO `##gedcom_setting` (gedcom_id, setting_name, setting_value)" .
416
			" SELECT :tree_id, setting_name, setting_value" .
417
			" FROM `##gedcom_setting` WHERE gedcom_id = -1"
418
		)->execute([
419
			'tree_id' => $tree_id,
420
		]);
421
422
		Database::prepare(
423
			"INSERT INTO `##default_resn` (gedcom_id, tag_type, resn)" .
424
			" SELECT :tree_id, tag_type, resn" .
425
			" FROM `##default_resn` WHERE gedcom_id = -1"
426
		)->execute([
427
			'tree_id' => $tree_id,
428
		]);
429
430
		Database::prepare(
431
			"INSERT INTO `##block` (gedcom_id, location, block_order, module_name)" .
432
			" SELECT :tree_id, location, block_order, module_name" .
433
			" FROM `##block` WHERE gedcom_id = -1"
434
		)->execute([
435
			'tree_id' => $tree_id,
436
		]);
437
438
		// Gedcom and privacy settings
439
		$tree->setPreference('CONTACT_USER_ID', Auth::id());
440
		$tree->setPreference('WEBMASTER_USER_ID', Auth::id());
441
		$tree->setPreference('LANGUAGE', WT_LOCALE); // Default to the current admin’s language
442
		switch (WT_LOCALE) {
443
			case 'es':
444
				$tree->setPreference('SURNAME_TRADITION', 'spanish');
445
				break;
446
			case 'is':
447
				$tree->setPreference('SURNAME_TRADITION', 'icelandic');
448
				break;
449
			case 'lt':
450
				$tree->setPreference('SURNAME_TRADITION', 'lithuanian');
451
				break;
452
			case 'pl':
453
				$tree->setPreference('SURNAME_TRADITION', 'polish');
454
				break;
455
			case 'pt':
456
			case 'pt-BR':
457
				$tree->setPreference('SURNAME_TRADITION', 'portuguese');
458
				break;
459
			default:
460
				$tree->setPreference('SURNAME_TRADITION', 'paternal');
461
				break;
462
		}
463
464
		// Genealogy data
465
		// It is simpler to create a temporary/unimported GEDCOM than to populate all the tables...
466
		$john_doe = /* I18N: This should be a common/default/placeholder name of an individual. Put slashes around the surname. */ I18N::translate('John /DOE/');
467
		$note     = I18N::translate('Edit this individual and replace their details with your own.');
468
		Database::prepare("INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)")->execute([
469
			$tree_id,
470
			"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",
471
		]);
472
473
		// Update our cache
474
		self::$trees[$tree->tree_id] = $tree;
475
476
		return $tree;
477
	}
478
479
	/**
480
	 * Are there any pending edits for this tree, than need reviewing by a moderator.
481
	 *
482
	 * @return bool
483
	 */
484
	public function hasPendingEdit() {
485
		return (bool) Database::prepare(
486
			"SELECT 1 FROM `##change` WHERE status = 'pending' AND gedcom_id = :tree_id"
487
		)->execute([
488
			'tree_id' => $this->tree_id,
489
		])->fetchOne();
490
	}
491
492
	/**
493
	 * Delete all the genealogy data from a tree - in preparation for importing
494
	 * new data. Optionally retain the media data, for when the user has been
495
	 * editing their data offline using an application which deletes (or does not
496
	 * support) media data.
497
	 *
498
	 * @param bool $keep_media
499
	 */
500
	public function deleteGenealogyData($keep_media) {
501
		Database::prepare("DELETE FROM `##gedcom_chunk` WHERE gedcom_id = ?")->execute([$this->tree_id]);
502
		Database::prepare("DELETE FROM `##individuals`  WHERE i_file    = ?")->execute([$this->tree_id]);
503
		Database::prepare("DELETE FROM `##families`     WHERE f_file    = ?")->execute([$this->tree_id]);
504
		Database::prepare("DELETE FROM `##sources`      WHERE s_file    = ?")->execute([$this->tree_id]);
505
		Database::prepare("DELETE FROM `##other`        WHERE o_file    = ?")->execute([$this->tree_id]);
506
		Database::prepare("DELETE FROM `##places`       WHERE p_file    = ?")->execute([$this->tree_id]);
507
		Database::prepare("DELETE FROM `##placelinks`   WHERE pl_file   = ?")->execute([$this->tree_id]);
508
		Database::prepare("DELETE FROM `##name`         WHERE n_file    = ?")->execute([$this->tree_id]);
509
		Database::prepare("DELETE FROM `##dates`        WHERE d_file    = ?")->execute([$this->tree_id]);
510
		Database::prepare("DELETE FROM `##change`       WHERE gedcom_id = ?")->execute([$this->tree_id]);
511
512
		if ($keep_media) {
513
			Database::prepare("DELETE FROM `##link` WHERE l_file =? AND l_type<>'OBJE'")->execute([$this->tree_id]);
514
		} else {
515
			Database::prepare("DELETE FROM `##link`  WHERE l_file =?")->execute([$this->tree_id]);
516
			Database::prepare("DELETE FROM `##media` WHERE m_file =?")->execute([$this->tree_id]);
517
			Database::prepare("DELETE FROM `##media_file` WHERE m_file =?")->execute([$this->tree_id]);
518
		}
519
	}
520
521
	/**
522
	 * Delete everything relating to a tree
523
	 */
524
	public function delete() {
525
		// If this is the default tree, then unset it
526
		if (Site::getPreference('DEFAULT_GEDCOM') === $this->name) {
527
			Site::setPreference('DEFAULT_GEDCOM', '');
528
		}
529
530
		$this->deleteGenealogyData(false);
531
532
		Database::prepare("DELETE `##block_setting` FROM `##block_setting` JOIN `##block` USING (block_id) WHERE gedcom_id=?")->execute([$this->tree_id]);
533
		Database::prepare("DELETE FROM `##block`               WHERE gedcom_id = ?")->execute([$this->tree_id]);
534
		Database::prepare("DELETE FROM `##user_gedcom_setting` WHERE gedcom_id = ?")->execute([$this->tree_id]);
535
		Database::prepare("DELETE FROM `##gedcom_setting`      WHERE gedcom_id = ?")->execute([$this->tree_id]);
536
		Database::prepare("DELETE FROM `##module_privacy`      WHERE gedcom_id = ?")->execute([$this->tree_id]);
537
		Database::prepare("DELETE FROM `##hit_counter`         WHERE gedcom_id = ?")->execute([$this->tree_id]);
538
		Database::prepare("DELETE FROM `##default_resn`        WHERE gedcom_id = ?")->execute([$this->tree_id]);
539
		Database::prepare("DELETE FROM `##gedcom_chunk`        WHERE gedcom_id = ?")->execute([$this->tree_id]);
540
		Database::prepare("DELETE FROM `##log`                 WHERE gedcom_id = ?")->execute([$this->tree_id]);
541
		Database::prepare("DELETE FROM `##gedcom`              WHERE gedcom_id = ?")->execute([$this->tree_id]);
542
543
		// After updating the database, we need to fetch a new (sorted) copy
544
		self::$trees = null;
545
	}
546
547
	/**
548
	 * Export the tree to a GEDCOM file
549
	 *
550
	 * @param resource $stream
551
	 */
552
	public function exportGedcom($stream) {
553
		$stmt = Database::prepare(
554
			"SELECT i_gedcom AS gedcom, i_id AS xref, 1 AS n FROM `##individuals` WHERE i_file = :tree_id_1" .
555
			" UNION ALL " .
556
			"SELECT f_gedcom AS gedcom, f_id AS xref, 2 AS n FROM `##families`    WHERE f_file = :tree_id_2" .
557
			" UNION ALL " .
558
			"SELECT s_gedcom AS gedcom, s_id AS xref, 3 AS n FROM `##sources`     WHERE s_file = :tree_id_3" .
559
			" UNION ALL " .
560
			"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')" .
561
			" UNION ALL " .
562
			"SELECT m_gedcom AS gedcom, m_id AS xref, 5 AS n FROM `##media`       WHERE m_file = :tree_id_5" .
563
			" ORDER BY n, LENGTH(xref), xref"
564
		)->execute([
565
			'tree_id_1' => $this->tree_id,
566
			'tree_id_2' => $this->tree_id,
567
			'tree_id_3' => $this->tree_id,
568
			'tree_id_4' => $this->tree_id,
569
			'tree_id_5' => $this->tree_id,
570
		]);
571
572
		$buffer = FunctionsExport::reformatRecord(FunctionsExport::gedcomHeader($this));
573
		while (($row = $stmt->fetch()) !== false) {
574
			$buffer .= FunctionsExport::reformatRecord($row->gedcom);
575
			if (strlen($buffer) > 65535) {
576
				fwrite($stream, $buffer);
577
				$buffer = '';
578
			}
579
		}
580
		fwrite($stream, $buffer . '0 TRLR' . Gedcom::EOL);
581
		$stmt->closeCursor();
582
	}
583
584
	/**
585
	 * Import data from a gedcom file into this tree.
586
	 *
587
	 * @param string  $path       The full path to the (possibly temporary) file.
588
	 * @param string  $filename   The preferred filename, for export/download.
589
	 *
590
	 * @throws \Exception
591
	 */
592
	public function importGedcomFile($path, $filename) {
593
		// Read the file in blocks of roughly 64K. Ensure that each block
594
		// contains complete gedcom records. This will ensure we don’t split
595
		// multi-byte characters, as well as simplifying the code to import
596
		// each block.
597
598
		$file_data = '';
599
		$fp        = fopen($path, 'rb');
600
601
		// Don’t allow the user to cancel the request. We do not want to be left with an incomplete transaction.
602
		ignore_user_abort(true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $value of ignore_user_abort(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

602
		ignore_user_abort(/** @scrutinizer ignore-type */ true);
Loading history...
603
604
		$this->deleteGenealogyData($this->getPreference('keep_media'));
0 ignored issues
show
Bug introduced by
$this->getPreference('keep_media') of type string is incompatible with the type boolean expected by parameter $keep_media of Fisharebest\Webtrees\Tree::deleteGenealogyData(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

604
		$this->deleteGenealogyData(/** @scrutinizer ignore-type */ $this->getPreference('keep_media'));
Loading history...
605
		$this->setPreference('gedcom_filename', $filename);
606
		$this->setPreference('imported', '0');
607
608
		while (!feof($fp)) {
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of feof() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

608
		while (!feof(/** @scrutinizer ignore-type */ $fp)) {
Loading history...
609
			$file_data .= fread($fp, 65536);
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

609
			$file_data .= fread(/** @scrutinizer ignore-type */ $fp, 65536);
Loading history...
610
			// There is no strrpos() function that searches for substrings :-(
611
			for ($pos = strlen($file_data) - 1; $pos > 0; --$pos) {
612
				if ($file_data[$pos] === '0' && ($file_data[$pos - 1] === "\n" || $file_data[$pos - 1] === "\r")) {
613
					// We’ve found the last record boundary in this chunk of data
614
					break;
615
				}
616
			}
617
			if ($pos) {
618
				Database::prepare(
619
					"INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)"
620
				)->execute([$this->tree_id, substr($file_data, 0, $pos)]);
621
				$file_data = substr($file_data, $pos);
622
			}
623
		}
624
		Database::prepare(
625
			"INSERT INTO `##gedcom_chunk` (gedcom_id, chunk_data) VALUES (?, ?)"
626
		)->execute([$this->tree_id, $file_data]);
627
628
		fclose($fp);
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

628
		fclose(/** @scrutinizer ignore-type */ $fp);
Loading history...
629
	}
630
631
	/**
632
	 * Generate a new XREF, unique across all family trees
633
	 *
634
	 * @return string
635
	 */
636
	public function getNewXref() {
637
		$prefix = 'X';
638
639
		$increment = 1.0;
640
		do {
641
			// Use LAST_INSERT_ID(expr) to provide a transaction-safe sequence. See
642
			// http://dev.mysql.com/doc/refman/5.6/en/information-functions.html#function_last-insert-id
643
			$statement = Database::prepare(
644
				"UPDATE `##site_setting` SET setting_value = LAST_INSERT_ID(setting_value + :increment) WHERE setting_name = 'next_xref'"
645
			);
646
			$statement->execute([
647
				'increment'   => (int) $increment,
648
			]);
649
650
			if ($statement->rowCount() === 0) {
651
				// First time we've used this record type.
652
				Site::setPreference('next_xref', '1');
653
				$num = 1;
654
			} else {
655
				$num = Database::prepare("SELECT LAST_INSERT_ID()")->fetchOne();
656
			}
657
658
			$xref = $prefix . $num;
659
660
			// Records may already exist with this sequence number.
661
			$already_used = Database::prepare(
662
				"SELECT" .
663
				" EXISTS (SELECT 1 FROM `##individuals` WHERE i_id = :i_id) OR" .
664
				" EXISTS (SELECT 1 FROM `##families` WHERE f_id = :f_id) OR" .
665
				" EXISTS (SELECT 1 FROM `##sources` WHERE s_id = :s_id) OR" .
666
				" EXISTS (SELECT 1 FROM `##media` WHERE m_id = :m_id) OR" .
667
				" EXISTS (SELECT 1 FROM `##other` WHERE o_id = :o_id) OR" .
668
				" EXISTS (SELECT 1 FROM `##change` WHERE xref = :xref)"
669
			)->execute([
670
				'i_id' => $xref,
671
				'f_id' => $xref,
672
				's_id' => $xref,
673
				'm_id' => $xref,
674
				'o_id' => $xref,
675
				'xref' => $xref,
676
			])->fetchOne();
677
678
			// This exponential increment allows us to scan over large blocks of
679
			// existing data in a reasonable time.
680
			$increment *= 1.01;
681
		} while ($already_used !== '0');
682
683
		return $xref;
684
	}
685
686
	/**
687
	 * Create a new record from GEDCOM data.
688
	 *
689
	 * @param string $gedcom
690
	 *
691
	 * @throws \Exception
692
	 *
693
	 * @return GedcomRecord|Individual|Family|Note|Source|Repository|Media
694
	 */
695
	public function createRecord($gedcom) {
696
		if (preg_match('/^0 @(' . WT_REGEX_XREF . ')@ (' . WT_REGEX_TAG . ')/', $gedcom, $match)) {
697
			$xref = $match[1];
698
			$type = $match[2];
699
		} else {
700
			throw new \Exception('Invalid argument to GedcomRecord::createRecord(' . $gedcom . ')');
701
		}
702
		if (strpos("\r", $gedcom) !== false) {
703
			// MSDOS line endings will break things in horrible ways
704
			throw new \Exception('Evil line endings found in GedcomRecord::createRecord(' . $gedcom . ')');
705
		}
706
707
		// webtrees creates XREFs containing digits. Anything else (e.g. “new”) is just a placeholder.
708
		if (!preg_match('/\d/', $xref)) {
709
			$xref   = $this->getNewXref();
710
			$gedcom = preg_replace('/^0 @(' . WT_REGEX_XREF . ')@/', '0 @' . $xref . '@', $gedcom);
711
		}
712
713
		// Create a change record, if not already present
714
		if (!preg_match('/\n1 CHAN/', $gedcom)) {
715
			$gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
716
		}
717
718
		// Create a pending change
719
		Database::prepare(
720
			"INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, '', ?, ?)"
721
		)->execute([
722
			$this->tree_id,
723
			$xref,
724
			$gedcom,
725
			Auth::id(),
726
		]);
727
728
		Log::addEditLog('Create: ' . $type . ' ' . $xref);
729
730
		// Accept this pending change
731
		if (Auth::user()->getPreference('auto_accept')) {
732
			FunctionsImport::acceptAllChanges($xref, $this->tree_id);
733
		}
734
		// Return the newly created record. Note that since GedcomRecord
735
		// has a cache of pending changes, we cannot use it to create a
736
		// record with a newly created pending change.
737
		return GedcomRecord::getInstance($xref, $this, $gedcom);
738
	}
739
740
	/**
741
	 * What is the most significant individual in this tree.
742
	 *
743
	 * @param User $user
744
	 *
745
	 * @return Individual
746
	 */
747
	public function significantIndividual(User $user): Individual {
748
		static $individual; // Only query the DB once.
749
750
		if (!$individual && $this->getUserPreference($user, 'rootid')) {
751
			$individual = Individual::getInstance($this->getUserPreference($user, 'rootid'), $this);
752
		}
753
		if (!$individual && $this->getUserPreference($user, 'gedcomid')) {
754
			$individual = Individual::getInstance($this->getUserPreference($user, 'gedcomid'), $this);
755
		}
756
		if (!$individual) {
757
			$individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this);
758
		}
759
		if (!$individual) {
760
			$individual = Individual::getInstance(
761
				Database::prepare(
762
					"SELECT MIN(i_id) FROM `##individuals` WHERE i_file=?"
763
				)->execute([$this->getTreeId()])->fetchOne(),
764
				$this
765
			);
766
		}
767
		if (!$individual) {
768
			// always return a record
769
			$individual = new Individual('I', '0 @I@ INDI', null, $this);
770
		}
771
772
		return $individual;
773
	}
774
}
775