Test Failed
Branch master (4a3c5b)
by Greg
12:31
created

GedcomRecord::linkedSources()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 23
Code Lines 18

Duplication

Lines 23
Ratio 100 %

Importance

Changes 0
Metric Value
cc 3
eloc 18
nc 3
nop 1
dl 23
loc 23
rs 9.0856
c 0
b 0
f 0
1
<?php
2
/**
3
 * webtrees: online genealogy
4
 * Copyright (C) 2017 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\Functions;
19
use Fisharebest\Webtrees\Functions\FunctionsDate;
20
use Fisharebest\Webtrees\Functions\FunctionsImport;
21
use Fisharebest\Webtrees\Functions\FunctionsPrint;
22
23
/**
24
 * A GEDCOM object.
25
 */
26
class GedcomRecord {
27
	const RECORD_TYPE = 'UNKNOWN';
28
	const URL_PREFIX  = 'gedrecord.php?pid=';
29
30
	/** @var string The record identifier */
31
	protected $xref;
32
33
	/** @var Tree  The family tree to which this record belongs */
34
	protected $tree;
35
36
	/** @var string  GEDCOM data (before any pending edits) */
37
	protected $gedcom;
38
39
	/** @var string|null  GEDCOM data (after any pending edits) */
40
	protected $pending;
41
42
	/** @var Fact[] facts extracted from $gedcom/$pending */
43
	protected $facts;
44
45
	/** @var bool Can we display details of this record to Auth::PRIV_PRIVATE */
46
	private $disp_public;
47
48
	/** @var bool Can we display details of this record to Auth::PRIV_USER */
49
	private $disp_user;
50
51
	/** @var bool Can we display details of this record to Auth::PRIV_NONE */
52
	private $disp_none;
53
54
	/** @var string[][] All the names of this individual */
55
	protected $_getAllNames;
56
57
	/** @var int Cached result */
58
	protected $_getPrimaryName;
59
60
	/** @var int Cached result */
61
	protected $_getSecondaryName;
62
63
	/** @var GedcomRecord[][] Allow getInstance() to return references to existing objects */
64
	protected static $gedcom_record_cache;
65
66
	/** @var \stdClass[][] Fetch all pending edits in one database query */
67
	private static $pending_record_cache;
68
69
	/**
70
	 * Create a GedcomRecord object from raw GEDCOM data.
71
	 *
72
	 * @param string      $xref
73
	 * @param string      $gedcom  an empty string for new/pending records
74
	 * @param string|null $pending null for a record with no pending edits,
75
	 *                             empty string for records with pending deletions
76
	 * @param Tree        $tree
77
	 */
78
	public function __construct($xref, $gedcom, $pending, $tree) {
79
		$this->xref    = $xref;
80
		$this->gedcom  = $gedcom;
81
		$this->pending = $pending;
82
		$this->tree    = $tree;
83
84
		$this->parseFacts();
85
	}
86
87
	/**
88
	 * Split the record into facts
89
	 */
90
	private function parseFacts() {
91
		// Split the record into facts
92
		if ($this->gedcom) {
93
			$gedcom_facts = preg_split('/\n(?=1)/s', $this->gedcom);
94
			array_shift($gedcom_facts);
0 ignored issues
show
Bug introduced by
It seems like $gedcom_facts can also be of type false; however, parameter $array of array_shift() does only seem to accept array, 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

94
			array_shift(/** @scrutinizer ignore-type */ $gedcom_facts);
Loading history...
95
		} else {
96
			$gedcom_facts = [];
97
		}
98
		if ($this->pending) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->pending of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
99
			$pending_facts = preg_split('/\n(?=1)/s', $this->pending);
100
			array_shift($pending_facts);
101
		} else {
102
			$pending_facts = [];
103
		}
104
105
		$this->facts = [];
106
107
		foreach ($gedcom_facts as $gedcom_fact) {
108
			$fact = new Fact($gedcom_fact, $this, md5($gedcom_fact));
109
			if ($this->pending !== null && !in_array($gedcom_fact, $pending_facts)) {
0 ignored issues
show
Bug introduced by
It seems like $pending_facts can also be of type false; however, parameter $haystack of in_array() does only seem to accept array, 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

109
			if ($this->pending !== null && !in_array($gedcom_fact, /** @scrutinizer ignore-type */ $pending_facts)) {
Loading history...
110
				$fact->setPendingDeletion();
111
			}
112
			$this->facts[] = $fact;
113
		}
114
		foreach ($pending_facts as $pending_fact) {
115
			if (!in_array($pending_fact, $gedcom_facts)) {
116
				$fact = new Fact($pending_fact, $this, md5($pending_fact));
117
				$fact->setPendingAddition();
118
				$this->facts[] = $fact;
119
			}
120
		}
121
	}
122
123
	/**
124
	 * Get an instance of a GedcomRecord object. For single records,
125
	 * we just receive the XREF. For bulk records (such as lists
126
	 * and search results) we can receive the GEDCOM data as well.
127
	 *
128
	 * @param string      $xref
129
	 * @param Tree        $tree
130
	 * @param string|null $gedcom
131
	 *
132
	 * @throws \Exception
133
	 *
134
	 * @return GedcomRecord|Individual|Family|Source|Repository|Media|Note|null
135
	 */
136
	public static function getInstance($xref, Tree $tree, $gedcom = null) {
137
		$tree_id = $tree->getTreeId();
138
139
		// Is this record already in the cache, and of the correct type?
140
		if (isset(self::$gedcom_record_cache[$xref][$tree_id])) {
141
			$record = self::$gedcom_record_cache[$xref][$tree_id];
142
143
			if ($record instanceof static) {
144
				return $record;
145
			} else {
146
				null;
147
			}
148
		}
149
150
		// Do we need to fetch the record from the database?
151
		if ($gedcom === null) {
152
			$gedcom = static::fetchGedcomRecord($xref, $tree_id);
153
		}
154
155
		// If we can edit, then we also need to be able to see pending records.
156
		if (Auth::isEditor($tree)) {
157
			if (!isset(self::$pending_record_cache[$tree_id])) {
158
				// Fetch all pending records in one database query
159
				self::$pending_record_cache[$tree_id] = [];
160
				$rows                                 = Database::prepare(
161
					"SELECT xref, new_gedcom FROM `##change` WHERE status = 'pending' AND gedcom_id = :tree_id ORDER BY change_id"
162
				)->execute([
163
					'tree_id' => $tree_id,
164
				])->fetchAll();
165
				foreach ($rows as $row) {
166
					self::$pending_record_cache[$tree_id][$row->xref] = $row->new_gedcom;
167
				}
168
			}
169
170
			if (isset(self::$pending_record_cache[$tree_id][$xref])) {
171
				// A pending edit exists for this record
172
				$pending = self::$pending_record_cache[$tree_id][$xref];
173
			} else {
174
				$pending = null;
175
			}
176
		} else {
177
			// There are no pending changes for this record
178
			$pending = null;
179
		}
180
181
		// No such record exists
182
		if ($gedcom === null && $pending === null) {
183
			return null;
184
		}
185
186
		// Create the object
187
		if (preg_match('/^0 @(' . WT_REGEX_XREF . ')@ (' . WT_REGEX_TAG . ')/', $gedcom . $pending, $match)) {
0 ignored issues
show
Bug introduced by
Are you sure $pending of type stdClass|null can be used in concatenation? ( Ignorable by Annotation )

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

187
		if (preg_match('/^0 @(' . WT_REGEX_XREF . ')@ (' . WT_REGEX_TAG . ')/', $gedcom . /** @scrutinizer ignore-type */ $pending, $match)) {
Loading history...
188
			$xref = $match[1]; // Collation - we may have requested I123 and found i123
189
			$type = $match[2];
190
		} elseif (preg_match('/^0 (HEAD|TRLR)/', $gedcom . $pending, $match)) {
191
			$xref = $match[1];
192
			$type = $match[1];
193
		} elseif ($gedcom . $pending) {
194
			throw new \Exception('Unrecognized GEDCOM record: ' . $gedcom);
195
		} else {
196
			// A record with both pending creation and pending deletion
197
			$type = static::RECORD_TYPE;
198
		}
199
200
		switch ($type) {
201
		case 'INDI':
202
			$record = new Individual($xref, $gedcom, $pending, $tree);
0 ignored issues
show
Bug introduced by
It seems like $pending can also be of type stdClass; however, parameter $pending of Fisharebest\Webtrees\Individual::__construct() does only seem to accept null|string, 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

202
			$record = new Individual($xref, $gedcom, /** @scrutinizer ignore-type */ $pending, $tree);
Loading history...
203
			break;
204
		case 'FAM':
205
			$record = new Family($xref, $gedcom, $pending, $tree);
0 ignored issues
show
Bug introduced by
It seems like $pending can also be of type stdClass; however, parameter $pending of Fisharebest\Webtrees\Family::__construct() does only seem to accept null|string, 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

205
			$record = new Family($xref, $gedcom, /** @scrutinizer ignore-type */ $pending, $tree);
Loading history...
206
			break;
207
		case 'SOUR':
208
			$record = new Source($xref, $gedcom, $pending, $tree);
0 ignored issues
show
Bug introduced by
It seems like $pending can also be of type stdClass; however, parameter $pending of Fisharebest\Webtrees\Source::__construct() does only seem to accept null|string, 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

208
			$record = new Source($xref, $gedcom, /** @scrutinizer ignore-type */ $pending, $tree);
Loading history...
209
			break;
210
		case 'OBJE':
211
			$record = new Media($xref, $gedcom, $pending, $tree);
0 ignored issues
show
Bug introduced by
It seems like $pending can also be of type stdClass; however, parameter $pending of Fisharebest\Webtrees\Media::__construct() does only seem to accept null|string, 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

211
			$record = new Media($xref, $gedcom, /** @scrutinizer ignore-type */ $pending, $tree);
Loading history...
212
			break;
213
		case 'REPO':
214
			$record = new Repository($xref, $gedcom, $pending, $tree);
0 ignored issues
show
Bug introduced by
It seems like $pending can also be of type stdClass; however, parameter $pending of Fisharebest\Webtrees\Repository::__construct() does only seem to accept null|string, 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

214
			$record = new Repository($xref, $gedcom, /** @scrutinizer ignore-type */ $pending, $tree);
Loading history...
215
			break;
216
		case 'NOTE':
217
			$record = new Note($xref, $gedcom, $pending, $tree);
0 ignored issues
show
Bug introduced by
It seems like $pending can also be of type stdClass; however, parameter $pending of Fisharebest\Webtrees\Note::__construct() does only seem to accept null|string, 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

217
			$record = new Note($xref, $gedcom, /** @scrutinizer ignore-type */ $pending, $tree);
Loading history...
218
			break;
219
		default:
220
			$record = new self($xref, $gedcom, $pending, $tree);
0 ignored issues
show
Bug introduced by
It seems like $pending can also be of type stdClass; however, parameter $pending of Fisharebest\Webtrees\GedcomRecord::__construct() does only seem to accept null|string, 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

220
			$record = new self($xref, $gedcom, /** @scrutinizer ignore-type */ $pending, $tree);
Loading history...
221
			break;
222
		}
223
224
		// Store it in the cache
225
		self::$gedcom_record_cache[$xref][$tree_id] = $record;
226
227
		return $record;
228
	}
229
230
	/**
231
	 * Fetch data from the database
232
	 *
233
	 * @param string $xref
234
	 * @param int    $tree_id
235
	 *
236
	 * @return null|string
237
	 */
238
	protected static function fetchGedcomRecord($xref, $tree_id) {
239
		// We don't know what type of object this is. Try each one in turn.
240
		$data = Individual::fetchGedcomRecord($xref, $tree_id);
241
		if ($data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
242
			return $data;
243
		}
244
		$data = Family::fetchGedcomRecord($xref, $tree_id);
245
		if ($data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
246
			return $data;
247
		}
248
		$data = Source::fetchGedcomRecord($xref, $tree_id);
249
		if ($data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
250
			return $data;
251
		}
252
		$data = Repository::fetchGedcomRecord($xref, $tree_id);
253
		if ($data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
254
			return $data;
255
		}
256
		$data = Media::fetchGedcomRecord($xref, $tree_id);
257
		if ($data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
258
			return $data;
259
		}
260
		$data = Note::fetchGedcomRecord($xref, $tree_id);
261
		if ($data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
262
			return $data;
263
		}
264
		// Some other type of record...
265
266
		return Database::prepare(
267
			"SELECT o_gedcom FROM `##other` WHERE o_id = :xref AND o_file = :tree_id"
268
		)->execute([
269
			'xref'    => $xref,
270
			'tree_id' => $tree_id,
271
		])->fetchOne();
272
	}
273
274
	/**
275
	 * Get the XREF for this record
276
	 *
277
	 * @return string
278
	 */
279
	public function getXref() {
280
		return $this->xref;
281
	}
282
283
	/**
284
	 * Get the tree to which this record belongs
285
	 *
286
	 * @return Tree
287
	 */
288
	public function getTree() {
289
		return $this->tree;
290
	}
291
292
	/**
293
	 * Application code should access data via Fact objects.
294
	 * This function exists to support old code.
295
	 *
296
	 * @return string
297
	 */
298
	public function getGedcom() {
299
		if ($this->pending === null) {
300
			return $this->gedcom;
301
		} else {
302
			return $this->pending;
303
		}
304
	}
305
306
	/**
307
	 * Does this record have a pending change?
308
	 *
309
	 * @return bool
310
	 */
311
	public function isPendingAddtion() {
312
		return $this->pending !== null;
313
	}
314
315
	/**
316
	 * Does this record have a pending deletion?
317
	 *
318
	 * @return bool
319
	 */
320
	public function isPendingDeletion() {
321
		return $this->pending === '';
322
	}
323
324
	/**
325
	 * Generate a URL to this record, suitable for use in HTML, etc.
326
	 *
327
	 * @return string
328
	 */
329
	public function getHtmlUrl() {
330
		return $this->getLinkUrl('&amp;');
331
	}
332
333
	/**
334
	 * Generate a URL to this record, suitable for use in javascript, HTTP headers, etc.
335
	 *
336
	 * @return string
337
	 */
338
	public function getRawUrl() {
339
		return $this->getLinkUrl('&');
340
	}
341
342
	/**
343
	 * Generate a URL to this record.
344
	 *
345
	 * @param string $separator
346
	 *
347
	 * @return string
348
	 */
349
	private function getLinkUrl($separator) {
350
		return static::URL_PREFIX . rawurlencode($this->getXref()) . $separator . 'ged=' . rawurlencode($this->tree->getName());
351
	}
352
353
	/**
354
	 * Work out whether this record can be shown to a user with a given access level
355
	 *
356
	 * @param int $access_level
357
	 *
358
	 * @return bool
359
	 */
360
	private function canShowRecord($access_level) {
361
		// This setting would better be called "$ENABLE_PRIVACY"
362
		if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) {
363
			return true;
364
		}
365
366
		// We should always be able to see our own record (unless an admin is applying download restrictions)
367
		if ($this->getXref() === $this->tree->getUserPreference(Auth::user(), 'gedcomid') && $access_level === Auth::accessLevel($this->tree)) {
368
			return true;
369
		}
370
371
		// Does this record have a RESN?
372
		if (strpos($this->gedcom, "\n1 RESN confidential")) {
373
			return Auth::PRIV_NONE >= $access_level;
374
		}
375
		if (strpos($this->gedcom, "\n1 RESN privacy")) {
376
			return Auth::PRIV_USER >= $access_level;
377
		}
378
		if (strpos($this->gedcom, "\n1 RESN none")) {
379
			return true;
380
		}
381
382
		// Does this record have a default RESN?
383
		$individual_privacy = $this->tree->getIndividualPrivacy();
384
		if (isset($individual_privacy[$this->getXref()])) {
385
			return $individual_privacy[$this->getXref()] >= $access_level;
386
		}
387
388
		// Privacy rules do not apply to admins
389
		if (Auth::PRIV_NONE >= $access_level) {
390
			return true;
391
		}
392
393
		// Different types of record have different privacy rules
394
		return $this->canShowByType($access_level);
395
	}
396
397
	/**
398
	 * Each object type may have its own special rules, and re-implement this function.
399
	 *
400
	 * @param int $access_level
401
	 *
402
	 * @return bool
403
	 */
404
	protected function canShowByType($access_level) {
405
		$fact_privacy = $this->tree->getFactPrivacy();
406
407
		if (isset($fact_privacy[static::RECORD_TYPE])) {
408
			// Restriction found
409
			return $fact_privacy[static::RECORD_TYPE] >= $access_level;
410
		} else {
411
			// No restriction found - must be public:
412
			return true;
413
		}
414
	}
415
416
	/**
417
	 * Can the details of this record be shown?
418
	 *
419
	 * @param int|null $access_level
420
	 *
421
	 * @return bool
422
	 */
423
	public function canShow($access_level = null) {
424
		if ($access_level === null) {
425
			$access_level = Auth::accessLevel($this->tree);
426
		}
427
428
		// CACHING: this function can take three different parameters,
429
		// and therefore needs three different caches for the result.
430
		switch ($access_level) {
431
		case Auth::PRIV_PRIVATE: // visitor
432
			if ($this->disp_public === null) {
433
				$this->disp_public = $this->canShowRecord(Auth::PRIV_PRIVATE);
434
			}
435
436
			return $this->disp_public;
437
		case Auth::PRIV_USER: // member
438
			if ($this->disp_user === null) {
439
				$this->disp_user = $this->canShowRecord(Auth::PRIV_USER);
440
			}
441
442
			return $this->disp_user;
443
		case Auth::PRIV_NONE: // admin
444
			if ($this->disp_none === null) {
445
				$this->disp_none = $this->canShowRecord(Auth::PRIV_NONE);
446
			}
447
448
			return $this->disp_none;
449
		case Auth::PRIV_HIDE: // hidden from admins
450
			// We use this value to bypass privacy checks. For example,
451
			// when downloading data or when calculating privacy itself.
452
			return true;
453
		default:
454
			// Should never get here.
455
			return false;
456
		}
457
	}
458
459
	/**
460
	 * Can the name of this record be shown?
461
	 *
462
	 * @param int|null $access_level
463
	 *
464
	 * @return bool
465
	 */
466
	public function canShowName($access_level = null) {
467
		if ($access_level === null) {
468
			$access_level = Auth::accessLevel($this->tree);
469
		}
470
471
		return $this->canShow($access_level);
472
	}
473
474
	/**
475
	 * Can we edit this record?
476
	 *
477
	 * @return bool
478
	 */
479
	public function canEdit() {
480
		return Auth::isManager($this->tree) || Auth::isEditor($this->tree) && strpos($this->gedcom, "\n1 RESN locked") === false;
481
	}
482
483
	/**
484
	 * Remove private data from the raw gedcom record.
485
	 * Return both the visible and invisible data. We need the invisible data when editing.
486
	 *
487
	 * @param int $access_level
488
	 *
489
	 * @return string
490
	 */
491
	public function privatizeGedcom($access_level) {
492
		if ($access_level == Auth::PRIV_HIDE) {
493
			// We may need the original record, for example when downloading a GEDCOM or clippings cart
494
			return $this->gedcom;
495
		} elseif ($this->canShow($access_level)) {
496
			// The record is not private, but the individual facts may be.
497
498
			// Include the entire first line (for NOTE records)
499
			list($gedrec) = explode("\n", $this->gedcom, 2);
500
501
			// Check each of the facts for access
502
			foreach ($this->getFacts(null, false, $access_level) as $fact) {
503
				$gedrec .= "\n" . $fact->getGedcom();
504
			}
505
506
			return $gedrec;
507
		} else {
508
			// We cannot display the details, but we may be able to display
509
			// limited data, such as links to other records.
510
			return $this->createPrivateGedcomRecord($access_level);
511
		}
512
	}
513
514
	/**
515
	 * Generate a private version of this record
516
	 *
517
	 * @param int $access_level
518
	 *
519
	 * @return string
520
	 */
521
	protected function createPrivateGedcomRecord($access_level) {
522
		return '0 @' . $this->xref . '@ ' . static::RECORD_TYPE . "\n1 NOTE " . I18N::translate('Private');
523
	}
524
525
	/**
526
	 * Convert a name record into sortable and full/display versions. This default
527
	 * should be OK for simple record types. INDI/FAM records will need to redefine it.
528
	 *
529
	 * @param string $type
530
	 * @param string $value
531
	 * @param string $gedcom
532
	 */
533
	protected function addName($type, $value, $gedcom) {
534
		$this->_getAllNames[] = [
535
			'type'   => $type,
536
			'sort'   => preg_replace_callback('/([0-9]+)/', function ($matches) {
537
				return str_pad($matches[0], 10, '0', STR_PAD_LEFT);
538
			}, $value),
539
			'full'   => '<span dir="auto">' . Html::escape($value) . '</span>', // This is used for display
540
			'fullNN' => $value, // This goes into the database
541
		];
542
	}
543
544
	/**
545
	 * Get all the names of a record, including ROMN, FONE and _HEB alternatives.
546
	 * Records without a name (e.g. FAM) will need to redefine this function.
547
	 * Parameters: the level 1 fact containing the name.
548
	 * Return value: an array of name structures, each containing
549
	 * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc.
550
	 * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown'
551
	 * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John'
552
	 *
553
	 * @param int    $level
554
	 * @param string $fact_type
555
	 * @param Fact[] $facts
556
	 */
557
	protected function extractNamesFromFacts($level, $fact_type, $facts) {
558
		$sublevel    = $level + 1;
559
		$subsublevel = $sublevel + 1;
560
		foreach ($facts as $fact) {
561
			if (preg_match_all("/^{$level} ({$fact_type}) (.+)((\n[{$sublevel}-9].+)*)/m", $fact->getGedcom(), $matches, PREG_SET_ORDER)) {
562
				foreach ($matches as $match) {
563
					// Treat 1 NAME / 2 TYPE married the same as _MARNM
564
					if ($match[1] == 'NAME' && strpos($match[3], "\n2 TYPE married") !== false) {
565
						$this->addName('_MARNM', $match[2], $fact->getGedcom());
566
					} else {
567
						$this->addName($match[1], $match[2], $fact->getGedcom());
568
					}
569
					if ($match[3] && preg_match_all("/^{$sublevel} (ROMN|FONE|_\w+) (.+)((\n[{$subsublevel}-9].+)*)/m", $match[3], $submatches, PREG_SET_ORDER)) {
570
						foreach ($submatches as $submatch) {
571
							$this->addName($submatch[1], $submatch[2], $match[3]);
572
						}
573
					}
574
				}
575
			}
576
		}
577
	}
578
579
	/**
580
	 * Default for "other" object types
581
	 */
582
	public function extractNames() {
583
		$this->addName(static::RECORD_TYPE, $this->getFallBackName(), null);
584
	}
585
586
	/**
587
	 * Derived classes should redefine this function, otherwise the object will have no name
588
	 *
589
	 * @return string[][]
590
	 */
591
	public function getAllNames() {
592
		if ($this->_getAllNames === null) {
593
			$this->_getAllNames = [];
594
			if ($this->canShowName()) {
595
				// Ask the record to extract its names
596
				$this->extractNames();
597
				// No name found? Use a fallback.
598
				if (!$this->_getAllNames) {
599
					$this->addName(static::RECORD_TYPE, $this->getFallBackName(), null);
600
				}
601
			} else {
602
				$this->addName(static::RECORD_TYPE, I18N::translate('Private'), null);
603
			}
604
		}
605
606
		return $this->_getAllNames;
607
	}
608
609
	/**
610
	 * If this object has no name, what do we call it?
611
	 *
612
	 * @return string
613
	 */
614
	public function getFallBackName() {
615
		return Html::escape($this->getXref());
616
	}
617
618
	/**
619
	 * Which of the (possibly several) names of this record is the primary one.
620
	 *
621
	 * @return int
622
	 */
623
	public function getPrimaryName() {
624
		static $language_script;
625
626
		if ($language_script === null) {
627
			$language_script = I18N::languageScript(WT_LOCALE);
628
		}
629
630
		if ($this->_getPrimaryName === null) {
631
			// Generally, the first name is the primary one....
632
			$this->_getPrimaryName = 0;
633
			// ...except when the language/name use different character sets
634
			if (count($this->getAllNames()) > 1) {
635
				foreach ($this->getAllNames() as $n => $name) {
636
					if (I18N::textScript($name['sort']) === $language_script) {
637
						$this->_getPrimaryName = $n;
638
						break;
639
					}
640
				}
641
			}
642
		}
643
644
		return $this->_getPrimaryName;
645
	}
646
647
	/**
648
	 * Which of the (possibly several) names of this record is the secondary one.
649
	 *
650
	 * @return int
651
	 */
652
	public function getSecondaryName() {
653
		if (is_null($this->_getSecondaryName)) {
654
			// Generally, the primary and secondary names are the same
655
			$this->_getSecondaryName = $this->getPrimaryName();
656
			// ....except when there are names with different character sets
657
			$all_names = $this->getAllNames();
658
			if (count($all_names) > 1) {
659
				$primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']);
660
				foreach ($all_names as $n => $name) {
661
					if ($n != $this->getPrimaryName() && $name['type'] != '_MARNM' && I18N::textScript($name['sort']) != $primary_script) {
662
						$this->_getSecondaryName = $n;
663
						break;
664
					}
665
				}
666
			}
667
		}
668
669
		return $this->_getSecondaryName;
670
	}
671
672
	/**
673
	 * Allow the choice of primary name to be overidden, e.g. in a search result
674
	 *
675
	 * @param int $n
676
	 */
677
	public function setPrimaryName($n) {
678
		$this->_getPrimaryName   = $n;
679
		$this->_getSecondaryName = null;
680
	}
681
682
	/**
683
	 * Allow native PHP functions such as array_unique() to work with objects
684
	 *
685
	 * @return string
686
	 */
687
	public function __toString() {
688
		return $this->xref . '@' . $this->tree->getTreeId();
689
	}
690
691
	/**
692
	 * Static helper function to sort an array of objects by name
693
	 * Records whose names cannot be displayed are sorted at the end.
694
	 *
695
	 * @param GedcomRecord $x
696
	 * @param GedcomRecord $y
697
	 *
698
	 * @return int
699
	 */
700
	public static function compare(GedcomRecord $x, GedcomRecord $y) {
701
		if ($x->canShowName()) {
702
			if ($y->canShowName()) {
703
				return I18N::strcasecmp($x->getSortName(), $y->getSortName());
704
			} else {
705
				return -1; // only $y is private
706
			}
707
		} else {
708
			if ($y->canShowName()) {
709
				return 1; // only $x is private
710
			} else {
711
				return 0; // both $x and $y private
712
			}
713
		}
714
	}
715
716
	/**
717
	 * Get variants of the name
718
	 *
719
	 * @return string
720
	 */
721
	public function getFullName() {
722
		if ($this->canShowName()) {
723
			$tmp = $this->getAllNames();
724
725
			return $tmp[$this->getPrimaryName()]['full'];
726
		} else {
727
			return I18N::translate('Private');
728
		}
729
	}
730
731
	/**
732
	 * Get a sortable version of the name. Do not display this!
733
	 *
734
	 * @return string
735
	 */
736
	public function getSortName() {
737
		// The sortable name is never displayed, no need to call canShowName()
738
		$tmp = $this->getAllNames();
739
740
		return $tmp[$this->getPrimaryName()]['sort'];
741
	}
742
743
	/**
744
	 * Get the full name in an alternative character set
745
	 *
746
	 * @return null|string
747
	 */
748
	public function getAddName() {
749
		if ($this->canShowName() && $this->getPrimaryName() != $this->getSecondaryName()) {
750
			$all_names = $this->getAllNames();
751
752
			return $all_names[$this->getSecondaryName()]['full'];
753
		} else {
754
			return null;
755
		}
756
	}
757
758
	/**
759
	 * Format this object for display in a list
760
	 * If $find is set, then we are displaying items from a selection list.
761
	 * $name allows us to use something other than the record name.
762
	 *
763
	 * @param string $tag
764
	 * @param bool   $find
765
	 * @param null   $name
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $name is correct as it would always require null to be passed?
Loading history...
766
	 *
767
	 * @return string
768
	 */
769
	public function formatList($tag = 'li', $find = false, $name = null) {
770
		if (is_null($name)) {
771
			$name = $this->getFullName();
772
		}
773
		$html = '<a href="' . $this->getHtmlUrl() . '"';
774
		if ($find) {
775
			$html .= ' onclick="pasteid(\'' . $this->getXref() . '\', \'' . htmlentities($name) . '\');"';
776
		}
777
		$html .= ' class="list_item"><b>' . $name . '</b>';
778
		$html .= $this->formatListDetails();
779
		$html = '<' . $tag . '>' . $html . '</a></' . $tag . '>';
780
781
		return $html;
782
	}
783
784
	/**
785
	 * This function should be redefined in derived classes to show any major
786
	 * identifying characteristics of this record.
787
	 *
788
	 * @return string
789
	 */
790
	public function formatListDetails() {
791
		return '';
792
	}
793
794
	/**
795
	 * Extract/format the first fact from a list of facts.
796
	 *
797
	 * @param string $facts
798
	 * @param int    $style
799
	 *
800
	 * @return string
801
	 */
802
	public function formatFirstMajorFact($facts, $style) {
803
		foreach ($this->getFacts($facts, true) as $event) {
804
			// Only display if it has a date or place (or both)
805
			if ($event->getDate()->isOK() && !$event->getPlace()->isEmpty()) {
806
				$joiner = ' — ';
807
			} else {
808
				$joiner = '';
809
			}
810
			if ($event->getDate()->isOK() || !$event->getPlace()->isEmpty()) {
811
				switch ($style) {
812 View Code Duplication
				case 1:
813
					return '<br><em>' . $event->getLabel() . ' ' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</em>';
814 View Code Duplication
				case 2:
815
					return '<dl><dt class="label">' . $event->getLabel() . '</dt><dd class="field">' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</dd></dl>';
816
				}
817
			}
818
		}
819
820
		return '';
821
	}
822
823
	/**
824
	 * Find individuals linked to this record.
825
	 *
826
	 * @param string $link
827
	 *
828
	 * @return Individual[]
829
	 */
830 View Code Duplication
	public function linkedIndividuals($link) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
831
		$rows = Database::prepare(
832
			"SELECT i_id AS xref, i_gedcom AS gedcom" .
833
			" FROM `##individuals`" .
834
			" JOIN `##link` ON i_file = l_file AND i_id = l_from" .
835
			" LEFT JOIN `##name` ON i_file = n_file AND i_id = n_id AND n_num = 0" .
836
			" WHERE i_file = :tree_id AND l_type = :link AND l_to = :xref" .
837
			" ORDER BY n_sort COLLATE :collation"
838
		)->execute([
839
			'tree_id'   => $this->tree->getTreeId(),
840
			'link'      => $link,
841
			'xref'      => $this->xref,
842
			'collation' => I18N::collation(),
843
		])->fetchAll();
844
845
		$list = [];
846
		foreach ($rows as $row) {
847
			$record = Individual::getInstance($row->xref, $this->tree, $row->gedcom);
848
			if ($record->canShowName()) {
849
				$list[] = $record;
850
			}
851
		}
852
853
		return $list;
854
	}
855
856
	/**
857
	 * Find families linked to this record.
858
	 *
859
	 * @param string $link
860
	 *
861
	 * @return Family[]
862
	 */
863 View Code Duplication
	public function linkedFamilies($link) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
864
		$rows = Database::prepare(
865
			"SELECT f_id AS xref, f_gedcom AS gedcom" .
866
			" FROM `##families`" .
867
			" JOIN `##link` ON f_file = l_file AND f_id = l_from" .
868
			" LEFT JOIN `##name` ON f_file = n_file AND f_id = n_id AND n_num = 0" .
869
			" WHERE f_file = :tree_id AND l_type = :link AND l_to = :xref"
870
		)->execute([
871
			'tree_id' => $this->tree->getTreeId(),
872
			'link'    => $link,
873
			'xref'    => $this->xref,
874
		])->fetchAll();
875
876
		$list = [];
877
		foreach ($rows as $row) {
878
			$record = Family::getInstance($row->xref, $this->tree, $row->gedcom);
879
			if ($record->canShowName()) {
880
				$list[] = $record;
881
			}
882
		}
883
884
		return $list;
885
	}
886
887
	/**
888
	 * Find sources linked to this record.
889
	 *
890
	 * @param string $link
891
	 *
892
	 * @return Source[]
893
	 */
894 View Code Duplication
	public function linkedSources($link) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
895
		$rows = Database::prepare(
896
			"SELECT s_id AS xref, s_gedcom AS gedcom" .
897
			" FROM `##sources`" .
898
			" JOIN `##link` ON s_file = l_file AND s_id = l_from" .
899
			" WHERE s_file = :tree_id AND l_type = :link AND l_to = :xref" .
900
			" ORDER BY s_name COLLATE :collation"
901
		)->execute([
902
			'tree_id'   => $this->tree->getTreeId(),
903
			'link'      => $link,
904
			'xref'      => $this->xref,
905
			'collation' => I18N::collation(),
906
		])->fetchAll();
907
908
		$list = [];
909
		foreach ($rows as $row) {
910
			$record = Source::getInstance($row->xref, $this->tree, $row->gedcom);
911
			if ($record->canShowName()) {
912
				$list[] = $record;
913
			}
914
		}
915
916
		return $list;
917
	}
918
919
	/**
920
	 * Find media objects linked to this record.
921
	 *
922
	 * @param string $link
923
	 *
924
	 * @return Media[]
925
	 */
926
	public function linkedMedia($link) {
927
		$rows = Database::prepare(
928
			"SELECT m_id AS xref, m_gedcom AS gedcom" .
929
			" FROM `##media`" .
930
			" JOIN `##link` ON m_file = l_file AND m_id = l_from" .
931
			" WHERE m_file = :tree_id AND l_type = :link AND l_to = :xref"
932
		)->execute([
933
			'tree_id'   => $this->tree->getTreeId(),
934
			'link'      => $link,
935
			'xref'      => $this->xref,
936
		])->fetchAll();
937
938
		$list = [];
939
		foreach ($rows as $row) {
940
			$record = Media::getInstance($row->xref, $this->tree, $row->gedcom);
941
			if ($record->canShowName()) {
942
				$list[] = $record;
943
			}
944
		}
945
946
		return $list;
947
	}
948
949
	/**
950
	 * Find notes linked to this record.
951
	 *
952
	 * @param string $link
953
	 *
954
	 * @return Note[]
955
	 */
956 View Code Duplication
	public function linkedNotes($link) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
957
		$rows = Database::prepare(
958
			"SELECT o_id AS xref, o_gedcom AS gedcom" .
959
			" FROM `##other`" .
960
			" JOIN `##link` ON o_file = l_file AND o_id = l_from" .
961
			" LEFT JOIN `##name` ON o_file = n_file AND o_id = n_id AND n_num = 0" .
962
			" WHERE o_file = :tree_id AND o_type = 'NOTE' AND l_type = :link AND l_to = :xref" .
963
			" ORDER BY n_sort COLLATE :collation"
964
		)->execute([
965
			'tree_id'   => $this->tree->getTreeId(),
966
			'link'      => $link,
967
			'xref'      => $this->xref,
968
			'collation' => I18N::collation(),
969
		])->fetchAll();
970
971
		$list = [];
972
		foreach ($rows as $row) {
973
			$record = Note::getInstance($row->xref, $this->tree, $row->gedcom);
974
			if ($record->canShowName()) {
975
				$list[] = $record;
976
			}
977
		}
978
979
		return $list;
980
	}
981
982
	/**
983
	 * Find repositories linked to this record.
984
	 *
985
	 * @param string $link
986
	 *
987
	 * @return Repository[]
988
	 */
989 View Code Duplication
	public function linkedRepositories($link) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
990
		$rows = Database::prepare(
991
			"SELECT o_id AS xref, o_gedcom AS gedcom" .
992
			" FROM `##other`" .
993
			" JOIN `##link` ON o_file = l_file AND o_id = l_from" .
994
			" LEFT JOIN `##name` ON o_file = n_file AND o_id = n_id AND n_num = 0" .
995
			" WHERE o_file = :tree_id AND o_type = 'REPO' AND l_type = :link AND l_to = :xref" .
996
			" ORDER BY n_sort COLLATE :collation"
997
		)->execute([
998
			'tree_id'   => $this->tree->getTreeId(),
999
			'link'      => $link,
1000
			'xref'      => $this->xref,
1001
			'collation' => I18N::collation(),
1002
		])->fetchAll();
1003
1004
		$list = [];
1005
		foreach ($rows as $row) {
1006
			$record = Repository::getInstance($row->xref, $this->tree, $row->gedcom);
1007
			if ($record->canShowName()) {
1008
				$list[] = $record;
1009
			}
1010
		}
1011
1012
		return $list;
1013
	}
1014
1015
	/**
1016
	 * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR).
1017
	 * This is used to display multiple events on the individual/family lists.
1018
	 * Multiple events can exist because of uncertainty in dates, dates in different
1019
	 * calendars, place-names in both latin and hebrew character sets, etc.
1020
	 * It also allows us to combine dates/places from different events in the summaries.
1021
	 *
1022
	 * @param string $event_type
1023
	 *
1024
	 * @return Date[]
1025
	 */
1026
	public function getAllEventDates($event_type) {
1027
		$dates = [];
1028
		foreach ($this->getFacts($event_type) as $event) {
1029
			if ($event->getDate()->isOK()) {
1030
				$dates[] = $event->getDate();
1031
			}
1032
		}
1033
1034
		return $dates;
1035
	}
1036
1037
	/**
1038
	 * Get all the places for a particular type of event
1039
	 *
1040
	 * @param string $event_type
1041
	 *
1042
	 * @return Place[]
1043
	 */
1044
	public function getAllEventPlaces($event_type) {
1045
		$places = [];
1046
		foreach ($this->getFacts($event_type) as $event) {
1047
			if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->getGedcom(), $ged_places)) {
1048
				foreach ($ged_places[1] as $ged_place) {
1049
					$places[] = new Place($ged_place, $this->tree);
1050
				}
1051
			}
1052
		}
1053
1054
		return $places;
1055
	}
1056
1057
	/**
1058
	 * Get the first (i.e. prefered) Fact for the given fact type
1059
	 *
1060
	 * @param string $tag
1061
	 *
1062
	 * @return Fact|null
1063
	 */
1064
	public function getFirstFact($tag) {
1065
		foreach ($this->getFacts() as $fact) {
1066
			if ($fact->getTag() === $tag) {
1067
				return $fact;
1068
			}
1069
		}
1070
1071
		return null;
1072
	}
1073
1074
	/**
1075
	 * The facts and events for this record.
1076
	 *
1077
	 * @param string    $filter
1078
	 * @param bool      $sort
1079
	 * @param int|null  $access_level
1080
	 * @param bool      $override     Include private records, to allow us to implement $SHOW_PRIVATE_RELATIONSHIPS and $SHOW_LIVING_NAMES.
1081
	 *
1082
	 * @return Fact[]
1083
	 */
1084
	public function getFacts($filter = null, $sort = false, $access_level = null, $override = false) {
1085
		if ($access_level === null) {
1086
			$access_level = Auth::accessLevel($this->tree);
1087
		}
1088
1089
		$facts = [];
1090
		if ($this->canShow($access_level) || $override) {
1091
			foreach ($this->facts as $fact) {
1092
				if (($filter === null || preg_match('/^' . $filter . '$/', $fact->getTag())) && $fact->canShow($access_level)) {
1093
					$facts[] = $fact;
1094
				}
1095
			}
1096
		}
1097
		if ($sort) {
1098
			Functions::sortFacts($facts);
1099
		}
1100
1101
		return $facts;
1102
	}
1103
1104
	/**
1105
	 * Get the last-change timestamp for this record, either as a formatted string
1106
	 * (for display) or as a unix timestamp (for sorting)
1107
	 *
1108
	 * @param bool $sorting
1109
	 *
1110
	 * @return string
1111
	 */
1112
	public function lastChangeTimestamp($sorting = false) {
1113
		$chan = $this->getFirstFact('CHAN');
1114
1115
		if ($chan) {
1116
			// The record does have a CHAN event
1117
			$d = $chan->getDate()->minimumDate();
1118
			if (preg_match('/\n3 TIME (\d\d):(\d\d):(\d\d)/', $chan->getGedcom(), $match)) {
1119
				$t = mktime((int) $match[1], (int) $match[2], (int) $match[3], (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y'));
1120
			} elseif (preg_match('/\n3 TIME (\d\d):(\d\d)/', $chan->getGedcom(), $match)) {
1121
				$t = mktime((int) $match[1], (int) $match[2], 0, (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y'));
1122
			} else {
1123
				$t = mktime(0, 0, 0, (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y'));
1124
			}
1125
			if ($sorting) {
1126
				return $t;
1127
			} else {
1128
				return strip_tags(FunctionsDate::formatTimestamp($t));
0 ignored issues
show
Bug introduced by
It seems like $t can also be of type false; however, parameter $time of Fisharebest\Webtrees\Fun...Date::formatTimestamp() 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

1128
				return strip_tags(FunctionsDate::formatTimestamp(/** @scrutinizer ignore-type */ $t));
Loading history...
1129
			}
1130
		} else {
1131
			// The record does not have a CHAN event
1132
			if ($sorting) {
1133
				return '0';
1134
			} else {
1135
				return '';
1136
			}
1137
		}
1138
	}
1139
1140
	/**
1141
	 * Get the last-change user for this record
1142
	 *
1143
	 * @return string
1144
	 */
1145
	public function lastChangeUser() {
1146
		$chan = $this->getFirstFact('CHAN');
1147
1148
		if ($chan === null) {
1149
			return I18N::translate('Unknown');
1150
		} else {
1151
			$chan_user = $chan->getAttribute('_WT_USER');
1152
			if ($chan_user === '') {
1153
				return I18N::translate('Unknown');
1154
			} else {
1155
				return $chan_user;
1156
			}
1157
		}
1158
	}
1159
1160
	/**
1161
	 * Add a new fact to this record
1162
	 *
1163
	 * @param string $gedcom
1164
	 * @param bool   $update_chan
1165
	 */
1166
	public function createFact($gedcom, $update_chan) {
1167
		$this->updateFact(null, $gedcom, $update_chan);
1168
	}
1169
1170
	/**
1171
	 * Delete a fact from this record
1172
	 *
1173
	 * @param string $fact_id
1174
	 * @param bool   $update_chan
1175
	 */
1176
	public function deleteFact($fact_id, $update_chan) {
1177
		$this->updateFact($fact_id, null, $update_chan);
1178
	}
1179
1180
	/**
1181
	 * Replace a fact with a new gedcom data.
1182
	 *
1183
	 * @param string $fact_id
1184
	 * @param string $gedcom
1185
	 * @param bool   $update_chan
1186
	 *
1187
	 * @throws \Exception
1188
	 */
1189
	public function updateFact($fact_id, $gedcom, $update_chan) {
1190
		// MSDOS line endings will break things in horrible ways
1191
		$gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1192
		$gedcom = trim($gedcom);
1193
1194
		if ($this->pending === '') {
1195
			throw new \Exception('Cannot edit a deleted record');
1196
		}
1197
		if ($gedcom && !preg_match('/^1 ' . WT_REGEX_TAG . '/', $gedcom)) {
1198
			throw new \Exception('Invalid GEDCOM data passed to GedcomRecord::updateFact(' . $gedcom . ')');
1199
		}
1200
1201
		if ($this->pending) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->pending of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1202
			$old_gedcom = $this->pending;
1203
		} else {
1204
			$old_gedcom = $this->gedcom;
1205
		}
1206
1207
		// First line of record may contain data - e.g. NOTE records.
1208
		list($new_gedcom) = explode("\n", $old_gedcom, 2);
1209
1210
		// Replacing (or deleting) an existing fact
1211
		foreach ($this->getFacts(null, false, Auth::PRIV_HIDE) as $fact) {
1212
			if (!$fact->isPendingDeletion()) {
1213
				if ($fact->getFactId() === $fact_id) {
1214
					if ($gedcom) {
1215
						$new_gedcom .= "\n" . $gedcom;
1216
					}
1217
					$fact_id = true; // Only replace/delete one copy of a duplicate fact
1218
				} elseif ($fact->getTag() != 'CHAN' || !$update_chan) {
1219
					$new_gedcom .= "\n" . $fact->getGedcom();
1220
				}
1221
			}
1222
		}
1223
		if ($update_chan) {
1224
			$new_gedcom .= "\n1 CHAN\n2 DATE " . strtoupper(date('d M Y')) . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
0 ignored issues
show
Bug introduced by
Are you sure date('H:i:s') of type false|string can be used in concatenation? ( Ignorable by Annotation )

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

1224
			$new_gedcom .= "\n1 CHAN\n2 DATE " . strtoupper(date('d M Y')) . "\n3 TIME " . /** @scrutinizer ignore-type */ date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
Loading history...
Bug introduced by
It seems like date('d M Y') can also be of type false; however, parameter $string of strtoupper() does only seem to accept string, 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

1224
			$new_gedcom .= "\n1 CHAN\n2 DATE " . strtoupper(/** @scrutinizer ignore-type */ date('d M Y')) . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
Loading history...
1225
		}
1226
1227
		// Adding a new fact
1228
		if (!$fact_id) {
1229
			$new_gedcom .= "\n" . $gedcom;
1230
		}
1231
1232
		if ($new_gedcom != $old_gedcom) {
1233
			// Save the changes
1234
			Database::prepare(
1235
				"INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, ?, ?)"
1236
			)->execute([
1237
				$this->tree->getTreeId(),
1238
				$this->xref,
1239
				$old_gedcom,
1240
				$new_gedcom,
1241
				Auth::id(),
1242
			]);
1243
1244
			$this->pending = $new_gedcom;
1245
1246 View Code Duplication
			if (Auth::user()->getPreference('auto_accept')) {
1247
				FunctionsImport::acceptAllChanges($this->xref, $this->tree->getTreeId());
1248
				$this->gedcom  = $new_gedcom;
1249
				$this->pending = null;
1250
			}
1251
		}
1252
		$this->parseFacts();
1253
	}
1254
1255
	/**
1256
	 * Update this record
1257
	 *
1258
	 * @param string $gedcom
1259
	 * @param bool   $update_chan
1260
	 */
1261
	public function updateRecord($gedcom, $update_chan) {
1262
		// MSDOS line endings will break things in horrible ways
1263
		$gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1264
		$gedcom = trim($gedcom);
1265
1266
		// Update the CHAN record
1267 View Code Duplication
		if ($update_chan) {
1268
			$gedcom = preg_replace('/\n1 CHAN(\n[2-9].*)*/', '', $gedcom);
1269
			$gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
0 ignored issues
show
Bug introduced by
Are you sure date('d M Y') of type false|string can be used in concatenation? ( Ignorable by Annotation )

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

1269
			$gedcom .= "\n1 CHAN\n2 DATE " . /** @scrutinizer ignore-type */ date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
Loading history...
Bug introduced by
Are you sure date('H:i:s') of type false|string can be used in concatenation? ( Ignorable by Annotation )

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

1269
			$gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . /** @scrutinizer ignore-type */ date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
Loading history...
1270
		}
1271
1272
		// Create a pending change
1273
		Database::prepare(
1274
			"INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, ?, ?)"
1275
		)->execute([
1276
			$this->tree->getTreeId(),
1277
			$this->xref,
1278
			$this->getGedcom(),
1279
			$gedcom,
1280
			Auth::id(),
1281
		]);
1282
1283
		// Clear the cache
1284
		$this->pending = $gedcom;
1285
1286
		// Accept this pending change
1287 View Code Duplication
		if (Auth::user()->getPreference('auto_accept')) {
1288
			FunctionsImport::acceptAllChanges($this->xref, $this->tree->getTreeId());
1289
			$this->gedcom  = $gedcom;
1290
			$this->pending = null;
1291
		}
1292
1293
		$this->parseFacts();
1294
1295
		Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref);
1296
	}
1297
1298
	/**
1299
	 * Delete this record
1300
	 */
1301
	public function deleteRecord() {
1302
		// Create a pending change
1303
		if (!$this->isPendingDeletion()) {
1304
			Database::prepare(
1305
				"INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, '', ?)"
1306
			)->execute([
1307
				$this->tree->getTreeId(),
1308
				$this->xref,
1309
				$this->getGedcom(),
1310
				Auth::id(),
1311
			]);
1312
		}
1313
1314
		// Auto-accept this pending change
1315
		if (Auth::user()->getPreference('auto_accept')) {
1316
			FunctionsImport::acceptAllChanges($this->xref, $this->tree->getTreeId());
1317
		}
1318
1319
		// Clear the cache
1320
		self::$gedcom_record_cache  = null;
1321
		self::$pending_record_cache = null;
1322
1323
		Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref);
1324
	}
1325
1326
	/**
1327
	 * Remove all links from this record to $xref
1328
	 *
1329
	 * @param string $xref
1330
	 * @param bool   $update_chan
1331
	 */
1332
	public function removeLinks($xref, $update_chan) {
1333
		$value = '@' . $xref . '@';
1334
1335
		foreach ($this->getFacts() as $fact) {
1336
			if ($fact->getValue() === $value) {
1337
				$this->deleteFact($fact->getFactId(), $update_chan);
1338
			} elseif (preg_match_all('/\n(\d) ' . WT_REGEX_TAG . ' ' . $value . '/', $fact->getGedcom(), $matches, PREG_SET_ORDER)) {
1339
				$gedcom = $fact->getGedcom();
1340
				foreach ($matches as $match) {
1341
					$next_level  = $match[1] + 1;
1342
					$next_levels = '[' . $next_level . '-9]';
1343
					$gedcom      = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom);
1344
				}
1345
				$this->updateFact($fact->getFactId(), $gedcom, $update_chan);
1346
			}
1347
		}
1348
	}
1349
}
1350