Completed
Push — develop ( 748dfd...e80729 )
by Greg
13:40 queued 04:27
created

GedcomRecord::getAbsoluteLinkUrl()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 3
rs 10
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);
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 string|null 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)) {
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)) {
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);
203
			break;
204
		case 'FAM':
205
			$record = new Family($xref, $gedcom, $pending, $tree);
206
			break;
207
		case 'SOUR':
208
			$record = new Source($xref, $gedcom, $pending, $tree);
209
			break;
210
		case 'OBJE':
211
			$record = new Media($xref, $gedcom, $pending, $tree);
212
			break;
213
		case 'REPO':
214
			$record = new Repository($xref, $gedcom, $pending, $tree);
215
			break;
216
		case 'NOTE':
217
			$record = new Note($xref, $gedcom, $pending, $tree);
218
			break;
219
		default:
220
			$record = new self($xref, $gedcom, $pending, $tree);
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);
0 ignored issues
show
Bug introduced by
The method fetchGedcomRecord() cannot be called from this context as it is declared protected in class Fisharebest\Webtrees\Individual.

This check looks for access to methods that are not accessible from the current context.

If you need to make a method accessible to another context you can raise its visibility level in the defining class.

Loading history...
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);
0 ignored issues
show
Bug introduced by
The method fetchGedcomRecord() cannot be called from this context as it is declared protected in class Fisharebest\Webtrees\Family.

This check looks for access to methods that are not accessible from the current context.

If you need to make a method accessible to another context you can raise its visibility level in the defining class.

Loading history...
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);
0 ignored issues
show
Bug introduced by
The method fetchGedcomRecord() cannot be called from this context as it is declared protected in class Fisharebest\Webtrees\Source.

This check looks for access to methods that are not accessible from the current context.

If you need to make a method accessible to another context you can raise its visibility level in the defining class.

Loading history...
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);
0 ignored issues
show
Bug introduced by
The method fetchGedcomRecord() cannot be called from this context as it is declared protected in class Fisharebest\Webtrees\Repository.

This check looks for access to methods that are not accessible from the current context.

If you need to make a method accessible to another context you can raise its visibility level in the defining class.

Loading history...
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);
0 ignored issues
show
Bug introduced by
The method fetchGedcomRecord() cannot be called from this context as it is declared protected in class Fisharebest\Webtrees\Media.

This check looks for access to methods that are not accessible from the current context.

If you need to make a method accessible to another context you can raise its visibility level in the defining class.

Loading history...
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);
0 ignored issues
show
Bug introduced by
The method fetchGedcomRecord() cannot be called from this context as it is declared protected in class Fisharebest\Webtrees\Note.

This check looks for access to methods that are not accessible from the current context.

If you need to make a method accessible to another context you can raise its visibility level in the defining class.

Loading history...
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(static::URL_PREFIX, '&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(static::URL_PREFIX, '&');
340
	}
341
342
	/**
343
	 * Generate a URL to this record.
344
	 *
345
	 * @param string $link
346
	 * @param string $separator
347
	 *
348
	 * @return string
349
	 */
350
	private function getLinkUrl($link, $separator) {
351
		return $link . $this->getXref() . $separator . 'ged=' . $this->tree->getNameUrl();
352
	}
353
354
	/**
355
	 * Work out whether this record can be shown to a user with a given access level
356
	 *
357
	 * @param int $access_level
358
	 *
359
	 * @return bool
360
	 */
361
	private function canShowRecord($access_level) {
362
		// This setting would better be called "$ENABLE_PRIVACY"
363
		if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) {
364
			return true;
365
		}
366
367
		// We should always be able to see our own record (unless an admin is applying download restrictions)
368
		if ($this->getXref() === $this->tree->getUserPreference(Auth::user(), 'gedcomid') && $access_level === Auth::accessLevel($this->tree)) {
369
			return true;
370
		}
371
372
		// Does this record have a RESN?
373
		if (strpos($this->gedcom, "\n1 RESN confidential")) {
374
			return Auth::PRIV_NONE >= $access_level;
375
		}
376
		if (strpos($this->gedcom, "\n1 RESN privacy")) {
377
			return Auth::PRIV_USER >= $access_level;
378
		}
379
		if (strpos($this->gedcom, "\n1 RESN none")) {
380
			return true;
381
		}
382
383
		// Does this record have a default RESN?
384
		$individual_privacy = $this->tree->getIndividualPrivacy();
385
		if (isset($individual_privacy[$this->getXref()])) {
386
			return $individual_privacy[$this->getXref()] >= $access_level;
387
		}
388
389
		// Privacy rules do not apply to admins
390
		if (Auth::PRIV_NONE >= $access_level) {
391
			return true;
392
		}
393
394
		// Different types of record have different privacy rules
395
		return $this->canShowByType($access_level);
396
	}
397
398
	/**
399
	 * Each object type may have its own special rules, and re-implement this function.
400
	 *
401
	 * @param int $access_level
402
	 *
403
	 * @return bool
404
	 */
405
	protected function canShowByType($access_level) {
406
		$fact_privacy = $this->tree->getFactPrivacy();
407
408
		if (isset($fact_privacy[static::RECORD_TYPE])) {
409
			// Restriction found
410
			return $fact_privacy[static::RECORD_TYPE] >= $access_level;
411
		} else {
412
			// No restriction found - must be public:
413
			return true;
414
		}
415
	}
416
417
	/**
418
	 * Can the details of this record be shown?
419
	 *
420
	 * @param int|null $access_level
421
	 *
422
	 * @return bool
423
	 */
424
	public function canShow($access_level = null) {
425
		if ($access_level === null) {
426
			$access_level = Auth::accessLevel($this->tree);
427
		}
428
429
// CACHING: this function can take three different parameters,
430
		// and therefore needs three different caches for the result.
431
		switch ($access_level) {
432
		case Auth::PRIV_PRIVATE: // visitor
433
			if ($this->disp_public === null) {
434
				$this->disp_public = $this->canShowRecord(Auth::PRIV_PRIVATE);
435
			}
436
437
			return $this->disp_public;
438
		case Auth::PRIV_USER: // member
439
			if ($this->disp_user === null) {
440
				$this->disp_user = $this->canShowRecord(Auth::PRIV_USER);
441
			}
442
443
			return $this->disp_user;
444
		case Auth::PRIV_NONE: // admin
445
			if ($this->disp_none === null) {
446
				$this->disp_none = $this->canShowRecord(Auth::PRIV_NONE);
447
			}
448
449
			return $this->disp_none;
450
		case Auth::PRIV_HIDE: // hidden from admins
451
			// We use this value to bypass privacy checks. For example,
452
			// when downloading data or when calculating privacy itself.
453
			return true;
454
		default:
455
			// Should never get here.
456
			return false;
457
		}
458
	}
459
460
	/**
461
	 * Can the name of this record be shown?
462
	 *
463
	 * @param int|null $access_level
464
	 *
465
	 * @return bool
466
	 */
467
	public function canShowName($access_level = null) {
468
		if ($access_level === null) {
469
			$access_level = Auth::accessLevel($this->tree);
470
		}
471
472
		return $this->canShow($access_level);
473
	}
474
475
	/**
476
	 * Can we edit this record?
477
	 *
478
	 * @return bool
479
	 */
480
	public function canEdit() {
481
		return Auth::isManager($this->tree) || Auth::isEditor($this->tree) && strpos($this->gedcom, "\n1 RESN locked") === false;
482
	}
483
484
	/**
485
	 * Remove private data from the raw gedcom record.
486
	 * Return both the visible and invisible data. We need the invisible data when editing.
487
	 *
488
	 * @param int $access_level
489
	 *
490
	 * @return string
491
	 */
492
	public function privatizeGedcom($access_level) {
493
		if ($access_level == Auth::PRIV_HIDE) {
494
			// We may need the original record, for example when downloading a GEDCOM or clippings cart
495
			return $this->gedcom;
496
		} elseif ($this->canShow($access_level)) {
497
			// The record is not private, but the individual facts may be.
498
499
			// Include the entire first line (for NOTE records)
500
			list($gedrec) = explode("\n", $this->gedcom, 2);
501
502
			// Check each of the facts for access
503
			foreach ($this->getFacts(null, false, $access_level) as $fact) {
504
				$gedrec .= "\n" . $fact->getGedcom();
505
			}
506
507
			return $gedrec;
508
		} else {
509
			// We cannot display the details, but we may be able to display
510
			// limited data, such as links to other records.
511
			return $this->createPrivateGedcomRecord($access_level);
512
		}
513
	}
514
515
	/**
516
	 * Generate a private version of this record
517
	 *
518
	 * @param int $access_level
519
	 *
520
	 * @return string
521
	 */
522
	protected function createPrivateGedcomRecord($access_level) {
523
		return '0 @' . $this->xref . '@ ' . static::RECORD_TYPE . "\n1 NOTE " . I18N::translate('Private');
524
	}
525
526
	/**
527
	 * Convert a name record into sortable and full/display versions. This default
528
	 * should be OK for simple record types. INDI/FAM records will need to redefine it.
529
	 *
530
	 * @param string $type
531
	 * @param string $value
532
	 * @param string $gedcom
533
	 */
534
	protected function addName($type, $value, $gedcom) {
535
		$this->_getAllNames[] = [
536
			'type'   => $type,
537
			'sort'   => preg_replace_callback('/([0-9]+)/', function ($matches) { return str_pad($matches[0], 10, '0', STR_PAD_LEFT); }, $value),
538
			'full'   => '<span dir="auto">' . Filter::escapeHtml($value) . '</span>', // This is used for display
539
			'fullNN' => $value, // This goes into the database
540
		];
541
	}
542
543
	/**
544
	 * Get all the names of a record, including ROMN, FONE and _HEB alternatives.
545
	 * Records without a name (e.g. FAM) will need to redefine this function.
546
	 * Parameters: the level 1 fact containing the name.
547
	 * Return value: an array of name structures, each containing
548
	 * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc.
549
	 * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown'
550
	 * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John'
551
	 *
552
	 * @param int    $level
553
	 * @param string $fact_type
554
	 * @param Fact[] $facts
555
	 */
556
	protected function extractNamesFromFacts($level, $fact_type, $facts) {
557
		$sublevel    = $level + 1;
558
		$subsublevel = $sublevel + 1;
559
		foreach ($facts as $fact) {
560
			if (preg_match_all("/^{$level} ({$fact_type}) (.+)((\n[{$sublevel}-9].+)*)/m", $fact->getGedcom(), $matches, PREG_SET_ORDER)) {
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $level instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $fact_type instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $sublevel instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
561
				foreach ($matches as $match) {
562
					// Treat 1 NAME / 2 TYPE married the same as _MARNM
563
					if ($match[1] == 'NAME' && strpos($match[3], "\n2 TYPE married") !== false) {
564
						$this->addName('_MARNM', $match[2], $fact->getGedcom());
565
					} else {
566
						$this->addName($match[1], $match[2], $fact->getGedcom());
567
					}
568
					if ($match[3] && preg_match_all("/^{$sublevel} (ROMN|FONE|_\w+) (.+)((\n[{$subsublevel}-9].+)*)/m", $match[3], $submatches, PREG_SET_ORDER)) {
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $sublevel instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $subsublevel instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
569
						foreach ($submatches as $submatch) {
570
							$this->addName($submatch[1], $submatch[2], $match[3]);
571
						}
572
					}
573
				}
574
			}
575
		}
576
	}
577
578
	/**
579
	 * Default for "other" object types
580
	 */
581
	public function extractNames() {
582
		$this->addName(static::RECORD_TYPE, $this->getFallBackName(), null);
583
	}
584
585
	/**
586
	 * Derived classes should redefine this function, otherwise the object will have no name
587
	 *
588
	 * @return string[][]
589
	 */
590
	public function getAllNames() {
591
		if ($this->_getAllNames === null) {
592
			$this->_getAllNames = [];
593
			if ($this->canShowName()) {
594
				// Ask the record to extract its names
595
				$this->extractNames();
596
				// No name found? Use a fallback.
597
				if (!$this->_getAllNames) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->_getAllNames of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
598
					$this->addName(static::RECORD_TYPE, $this->getFallBackName(), null);
599
				}
600
			} else {
601
				$this->addName(static::RECORD_TYPE, I18N::translate('Private'), null);
602
			}
603
		}
604
605
		return $this->_getAllNames;
606
	}
607
608
	/**
609
	 * If this object has no name, what do we call it?
610
	 *
611
	 * @return string
612
	 */
613
	public function getFallBackName() {
614
		return $this->getXref();
615
	}
616
617
	/**
618
	 * Which of the (possibly several) names of this record is the primary one.
619
	 *
620
	 * @return int
621
	 */
622
	public function getPrimaryName() {
623
		static $language_script;
624
625
		if ($language_script === null) {
626
			$language_script = I18N::languageScript(WT_LOCALE);
627
		}
628
629
		if ($this->_getPrimaryName === null) {
630
			// Generally, the first name is the primary one....
631
			$this->_getPrimaryName = 0;
632
			// ...except when the language/name use different character sets
633
			if (count($this->getAllNames()) > 1) {
634
				foreach ($this->getAllNames() as $n => $name) {
635
					if ($name['type'] !== '_MARNM' && I18N::textScript($name['sort']) === $language_script) {
636
						$this->_getPrimaryName = $n;
637
						break;
638
					}
639
				}
640
			}
641
		}
642
643
		return $this->_getPrimaryName;
644
	}
645
646
	/**
647
	 * Which of the (possibly several) names of this record is the secondary one.
648
	 *
649
	 * @return int
650
	 */
651
	public function getSecondaryName() {
652
		if (is_null($this->_getSecondaryName)) {
653
			// Generally, the primary and secondary names are the same
654
			$this->_getSecondaryName = $this->getPrimaryName();
655
			// ....except when there are names with different character sets
656
			$all_names = $this->getAllNames();
657
			if (count($all_names) > 1) {
658
				$primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']);
659
				foreach ($all_names as $n => $name) {
660
					if ($n != $this->getPrimaryName() && $name['type'] != '_MARNM' && I18N::textScript($name['sort']) != $primary_script) {
661
						$this->_getSecondaryName = $n;
662
						break;
663
					}
664
				}
665
			}
666
		}
667
668
		return $this->_getSecondaryName;
669
	}
670
671
	/**
672
	 * Allow the choice of primary name to be overidden, e.g. in a search result
673
	 *
674
	 * @param int $n
675
	 */
676
	public function setPrimaryName($n) {
677
		$this->_getPrimaryName   = $n;
678
		$this->_getSecondaryName = null;
679
	}
680
681
	/**
682
	 * Allow native PHP functions such as array_unique() to work with objects
683
	 *
684
	 * @return string
685
	 */
686
	public function __toString() {
687
		return $this->xref . '@' . $this->tree->getTreeId();
688
	}
689
690
	/**
691
	 * Static helper function to sort an array of objects by name
692
	 * Records whose names cannot be displayed are sorted at the end.
693
	 *
694
	 * @param GedcomRecord $x
695
	 * @param GedcomRecord $y
696
	 *
697
	 * @return int
698
	 */
699
	public static function compare(GedcomRecord $x, GedcomRecord $y) {
700
		if ($x->canShowName()) {
701
			if ($y->canShowName()) {
702
				return I18N::strcasecmp($x->getSortName(), $y->getSortName());
703
			} else {
704
				return -1; // only $y is private
705
			}
706
		} else {
707
			if ($y->canShowName()) {
708
				return 1; // only $x is private
709
			} else {
710
				return 0; // both $x and $y private
0 ignored issues
show
Unused Code Comprehensibility introduced by
40% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
711
			}
712
		}
713
	}
714
715
	/**
716
	 * Get variants of the name
717
	 *
718
	 * @return string
719
	 */
720
	public function getFullName() {
721
		if ($this->canShowName()) {
722
			$tmp = $this->getAllNames();
723
724
			return $tmp[$this->getPrimaryName()]['full'];
725
		} else {
726
			return I18N::translate('Private');
727
		}
728
	}
729
730
	/**
731
	 * Get a sortable version of the name. Do not display this!
732
	 *
733
	 * @return string
734
	 */
735
	public function getSortName() {
736
		// The sortable name is never displayed, no need to call canShowName()
737
		$tmp = $this->getAllNames();
738
739
		return $tmp[$this->getPrimaryName()]['sort'];
740
	}
741
742
	/**
743
	 * Get the full name in an alternative character set
744
	 *
745
	 * @return null|string
746
	 */
747
	public function getAddName() {
748
		if ($this->canShowName() && $this->getPrimaryName() != $this->getSecondaryName()) {
749
			$all_names = $this->getAllNames();
750
751
			return $all_names[$this->getSecondaryName()]['full'];
752
		} else {
753
			return null;
754
		}
755
	}
756
757
	/**
758
	 * Format this object for display in a list
759
	 * If $find is set, then we are displaying items from a selection list.
760
	 * $name allows us to use something other than the record name.
761
	 *
762
	 * @param string $tag
763
	 * @param bool   $find
764
	 * @param null   $name
765
	 *
766
	 * @return string
767
	 */
768
	public function formatList($tag = 'li', $find = false, $name = null) {
769
		if (is_null($name)) {
770
			$name = $this->getFullName();
771
		}
772
		$html = '<a href="' . $this->getHtmlUrl() . '"';
773
		if ($find) {
774
			$html .= ' onclick="pasteid(\'' . $this->getXref() . '\', \'' . htmlentities($name) . '\');"';
775
		}
776
		$html .= ' class="list_item"><b>' . $name . '</b>';
777
		$html .= $this->formatListDetails();
778
		$html = '<' . $tag . '>' . $html . '</a></' . $tag . '>';
779
780
		return $html;
781
	}
782
783
	/**
784
	 * This function should be redefined in derived classes to show any major
785
	 * identifying characteristics of this record.
786
	 *
787
	 * @return string
788
	 */
789
	public function formatListDetails() {
790
		return '';
791
	}
792
793
	/**
794
	 * Extract/format the first fact from a list of facts.
795
	 *
796
	 * @param string $facts
797
	 * @param int    $style
798
	 *
799
	 * @return string
800
	 */
801
	public function formatFirstMajorFact($facts, $style) {
802
		foreach ($this->getFacts($facts, true) as $event) {
803
			// Only display if it has a date or place (or both)
804
			if ($event->getDate()->isOK() && !$event->getPlace()->isEmpty()) {
805
				$joiner = ' — ';
806
			} else {
807
				$joiner = '';
808
			}
809
			if ($event->getDate()->isOK() || !$event->getPlace()->isEmpty()) {
810
				switch ($style) {
811 View Code Duplication
				case 1:
812
					return '<br><em>' . $event->getLabel() . ' ' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</em>';
813 View Code Duplication
				case 2:
814
					return '<dl><dt class="label">' . $event->getLabel() . '</dt><dd class="field">' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</dd></dl>';
815
				}
816
			}
817
		}
818
819
		return '';
820
	}
821
822
	/**
823
	 * Find individuals linked to this record.
824
	 *
825
	 * @param string $link
826
	 *
827
	 * @return Individual[]
828
	 */
829 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...
830
		$rows = Database::prepare(
831
			"SELECT i_id AS xref, i_gedcom AS gedcom" .
832
			" FROM `##individuals`" .
833
			" JOIN `##link` ON i_file = l_file AND i_id = l_from" .
834
			" LEFT JOIN `##name` ON i_file = n_file AND i_id = n_id AND n_num = 0" .
835
			" WHERE i_file = :tree_id AND l_type = :link AND l_to = :xref" .
836
			" ORDER BY n_sort COLLATE :collation"
837
		)->execute([
838
			'tree_id'   => $this->tree->getTreeId(),
839
			'link'      => $link,
840
			'xref'      => $this->xref,
841
			'collation' => I18N::collation(),
842
		])->fetchAll();
843
844
		$list = [];
845
		foreach ($rows as $row) {
846
			$record = Individual::getInstance($row->xref, $this->tree, $row->gedcom);
847
			if ($record->canShowName()) {
848
				$list[] = $record;
849
			}
850
		}
851
852
		return $list;
853
	}
854
855
	/**
856
	 * Find families linked to this record.
857
	 *
858
	 * @param string $link
859
	 *
860
	 * @return Family[]
861
	 */
862 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...
863
		$rows = Database::prepare(
864
			"SELECT f_id AS xref, f_gedcom AS gedcom" .
865
			" FROM `##families`" .
866
			" JOIN `##link` ON f_file = l_file AND f_id = l_from" .
867
			" LEFT JOIN `##name` ON f_file = n_file AND f_id = n_id AND n_num = 0" .
868
			" WHERE f_file = :tree_id AND l_type = :link AND l_to = :xref"
869
		)->execute([
870
			'tree_id' => $this->tree->getTreeId(),
871
			'link'    => $link,
872
			'xref'    => $this->xref,
873
		])->fetchAll();
874
875
		$list = [];
876
		foreach ($rows as $row) {
877
			$record = Family::getInstance($row->xref, $this->tree, $row->gedcom);
878
			if ($record->canShowName()) {
879
				$list[] = $record;
880
			}
881
		}
882
883
		return $list;
884
	}
885
886
	/**
887
	 * Find sources linked to this record.
888
	 *
889
	 * @param string $link
890
	 *
891
	 * @return Source[]
892
	 */
893 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...
894
		$rows = Database::prepare(
895
			"SELECT s_id AS xref, s_gedcom AS gedcom" .
896
			" FROM `##sources`" .
897
			" JOIN `##link` ON s_file = l_file AND s_id = l_from" .
898
			" WHERE s_file = :tree_id AND l_type = :link AND l_to = :xref" .
899
			" ORDER BY s_name COLLATE :collation"
900
		)->execute([
901
			'tree_id'   => $this->tree->getTreeId(),
902
			'link'      => $link,
903
			'xref'      => $this->xref,
904
			'collation' => I18N::collation(),
905
		])->fetchAll();
906
907
		$list = [];
908
		foreach ($rows as $row) {
909
			$record = Source::getInstance($row->xref, $this->tree, $row->gedcom);
910
			if ($record->canShowName()) {
911
				$list[] = $record;
912
			}
913
		}
914
915
		return $list;
916
	}
917
918
	/**
919
	 * Find media objects linked to this record.
920
	 *
921
	 * @param string $link
922
	 *
923
	 * @return Media[]
924
	 */
925 View Code Duplication
	public function linkedMedia($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...
926
		$rows = Database::prepare(
927
			"SELECT m_id AS xref, m_gedcom AS gedcom" .
928
			" FROM `##media`" .
929
			" JOIN `##link` ON m_file = l_file AND m_id = l_from" .
930
			" WHERE m_file = :tree_id AND l_type = :link AND l_to = :xref" .
931
			" ORDER BY m_titl COLLATE :collation"
932
		)->execute([
933
			'tree_id'   => $this->tree->getTreeId(),
934
			'link'      => $link,
935
			'xref'      => $this->xref,
936
			'collation' => I18N::collation(),
937
		])->fetchAll();
938
939
		$list = [];
940
		foreach ($rows as $row) {
941
			$record = Media::getInstance($row->xref, $this->tree, $row->gedcom);
942
			if ($record->canShowName()) {
943
				$list[] = $record;
944
			}
945
		}
946
947
		return $list;
948
	}
949
950
	/**
951
	 * Find notes linked to this record.
952
	 *
953
	 * @param string $link
954
	 *
955
	 * @return Note[]
956
	 */
957 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...
958
		$rows = Database::prepare(
959
			"SELECT o_id AS xref, o_gedcom AS gedcom" .
960
			" FROM `##other`" .
961
			" JOIN `##link` ON o_file = l_file AND o_id = l_from" .
962
			" LEFT JOIN `##name` ON o_file = n_file AND o_id = n_id AND n_num = 0" .
963
			" WHERE o_file = :tree_id AND o_type = 'NOTE' AND l_type = :link AND l_to = :xref" .
964
			" ORDER BY n_sort COLLATE :collation"
965
		)->execute([
966
			'tree_id'   => $this->tree->getTreeId(),
967
			'link'      => $link,
968
			'xref'      => $this->xref,
969
			'collation' => I18N::collation(),
970
		])->fetchAll();
971
972
		$list = [];
973
		foreach ($rows as $row) {
974
			$record = Note::getInstance($row->xref, $this->tree, $row->gedcom);
975
			if ($record->canShowName()) {
976
				$list[] = $record;
977
			}
978
		}
979
980
		return $list;
981
	}
982
983
	/**
984
	 * Find repositories linked to this record.
985
	 *
986
	 * @param string $link
987
	 *
988
	 * @return Repository[]
989
	 */
990 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...
991
		$rows = Database::prepare(
992
			"SELECT o_id AS xref, o_gedcom AS gedcom" .
993
			" FROM `##other`" .
994
			" JOIN `##link` ON o_file = l_file AND o_id = l_from" .
995
			" LEFT JOIN `##name` ON o_file = n_file AND o_id = n_id AND n_num = 0" .
996
			" WHERE o_file = :tree_id AND o_type = 'REPO' AND l_type = :link AND l_to = :xref" .
997
			" ORDER BY n_sort COLLATE :collation"
998
		)->execute([
999
			'tree_id'   => $this->tree->getTreeId(),
1000
			'link'      => $link,
1001
			'xref'      => $this->xref,
1002
			'collation' => I18N::collation(),
1003
		])->fetchAll();
1004
1005
		$list = [];
1006
		foreach ($rows as $row) {
1007
			$record = Repository::getInstance($row->xref, $this->tree, $row->gedcom);
1008
			if ($record->canShowName()) {
1009
				$list[] = $record;
1010
			}
1011
		}
1012
1013
		return $list;
1014
	}
1015
1016
	/**
1017
	 * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR).
1018
	 * This is used to display multiple events on the individual/family lists.
1019
	 * Multiple events can exist because of uncertainty in dates, dates in different
1020
	 * calendars, place-names in both latin and hebrew character sets, etc.
1021
	 * It also allows us to combine dates/places from different events in the summaries.
1022
	 *
1023
	 * @param string $event_type
1024
	 *
1025
	 * @return Date[]
1026
	 */
1027
	public function getAllEventDates($event_type) {
1028
		$dates = [];
1029
		foreach ($this->getFacts($event_type) as $event) {
1030
			if ($event->getDate()->isOK()) {
1031
				$dates[] = $event->getDate();
1032
			}
1033
		}
1034
1035
		return $dates;
1036
	}
1037
1038
	/**
1039
	 * Get all the places for a particular type of event
1040
	 *
1041
	 * @param string $event_type
1042
	 *
1043
	 * @return array
1044
	 */
1045
	public function getAllEventPlaces($event_type) {
1046
		$places = [];
1047
		foreach ($this->getFacts($event_type) as $event) {
1048
			if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->getGedcom(), $ged_places)) {
1049
				foreach ($ged_places[1] as $ged_place) {
1050
					$places[] = $ged_place;
1051
				}
1052
			}
1053
		}
1054
1055
		return $places;
1056
	}
1057
1058
	/**
1059
	 * Get the first (i.e. prefered) Fact for the given fact type
1060
	 *
1061
	 * @param string $tag
1062
	 *
1063
	 * @return Fact|null
1064
	 */
1065
	public function getFirstFact($tag) {
1066
		foreach ($this->getFacts() as $fact) {
1067
			if ($fact->getTag() === $tag) {
1068
				return $fact;
1069
			}
1070
		}
1071
1072
		return null;
1073
	}
1074
1075
	/**
1076
	 * The facts and events for this record.
1077
	 *
1078
	 * @param string    $filter
0 ignored issues
show
Documentation introduced by
Should the type for parameter $filter not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
1079
	 * @param bool      $sort
1080
	 * @param int|null  $access_level
1081
	 * @param bool      $override     Include private records, to allow us to implement $SHOW_PRIVATE_RELATIONSHIPS and $SHOW_LIVING_NAMES.
1082
	 *
1083
	 * @return Fact[]
1084
	 */
1085
	public function getFacts($filter = null, $sort = false, $access_level = null, $override = false) {
1086
		if ($access_level === null) {
1087
			$access_level = Auth::accessLevel($this->tree);
1088
		}
1089
1090
			$facts = [];
1091
		if ($this->canShow($access_level) || $override) {
1092
			foreach ($this->facts as $fact) {
1093
				if (($filter === null || preg_match('/^' . $filter . '$/', $fact->getTag())) && $fact->canShow($access_level)) {
1094
					$facts[] = $fact;
1095
				}
1096
			}
1097
		}
1098
		if ($sort) {
1099
			Functions::sortFacts($facts);
1100
		}
1101
1102
		return $facts;
1103
	}
1104
1105
	/**
1106
	 * Get the last-change timestamp for this record, either as a formatted string
1107
	 * (for display) or as a unix timestamp (for sorting)
1108
	 *
1109
	 * @param bool $sorting
1110
	 *
1111
	 * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|string?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
1112
	 */
1113
	public function lastChangeTimestamp($sorting = false) {
1114
		$chan = $this->getFirstFact('CHAN');
1115
1116
		if ($chan) {
1117
			// The record does have a CHAN event
1118
			$d = $chan->getDate()->minimumDate();
1119
			if (preg_match('/\n3 TIME (\d\d):(\d\d):(\d\d)/', $chan->getGedcom(), $match)) {
1120
				$t = mktime((int) $match[1], (int) $match[2], (int) $match[3], (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y'));
1121
			} elseif (preg_match('/\n3 TIME (\d\d):(\d\d)/', $chan->getGedcom(), $match)) {
1122
				$t = mktime((int) $match[1], (int) $match[2], 0, (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y'));
1123
			} else {
1124
				$t = mktime(0, 0, 0, (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y'));
1125
			}
1126
			if ($sorting) {
1127
				return $t;
1128
			} else {
1129
				return strip_tags(FunctionsDate::formatTimestamp($t));
1130
			}
1131
		} else {
1132
			// The record does not have a CHAN event
1133
			if ($sorting) {
1134
				return '0';
1135
			} else {
1136
				return '';
1137
			}
1138
		}
1139
	}
1140
1141
	/**
1142
	 * Get the last-change user for this record
1143
	 *
1144
	 * @return string
1145
	 */
1146
	public function lastChangeUser() {
1147
		$chan = $this->getFirstFact('CHAN');
1148
1149
		if ($chan === null) {
1150
			return I18N::translate('Unknown');
1151
		} else {
1152
			$chan_user = $chan->getAttribute('_WT_USER');
1153
			if ($chan_user === '') {
1154
				return I18N::translate('Unknown');
1155
			} else {
1156
				return $chan_user;
1157
			}
1158
		}
1159
	}
1160
1161
	/**
1162
	 * Add a new fact to this record
1163
	 *
1164
	 * @param string $gedcom
1165
	 * @param bool   $update_chan
1166
	 */
1167
	public function createFact($gedcom, $update_chan) {
1168
		$this->updateFact(null, $gedcom, $update_chan);
1169
	}
1170
1171
	/**
1172
	 * Delete a fact from this record
1173
	 *
1174
	 * @param string $fact_id
1175
	 * @param bool   $update_chan
1176
	 */
1177
	public function deleteFact($fact_id, $update_chan) {
1178
		$this->updateFact($fact_id, null, $update_chan);
1179
	}
1180
1181
	/**
1182
	 * Replace a fact with a new gedcom data.
1183
	 *
1184
	 * @param string $fact_id
1185
	 * @param string $gedcom
1186
	 * @param bool   $update_chan
1187
	 *
1188
	 * @throws \Exception
1189
	 */
1190
	public function updateFact($fact_id, $gedcom, $update_chan) {
1191
		// MSDOS line endings will break things in horrible ways
1192
		$gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1193
		$gedcom = trim($gedcom);
1194
1195
		if ($this->pending === '') {
1196
			throw new \Exception('Cannot edit a deleted record');
1197
		}
1198
		if ($gedcom && !preg_match('/^1 ' . WT_REGEX_TAG . '/', $gedcom)) {
1199
			throw new \Exception('Invalid GEDCOM data passed to GedcomRecord::updateFact(' . $gedcom . ')');
1200
		}
1201
1202
		if ($this->pending) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->pending of type string|null 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...
1203
			$old_gedcom = $this->pending;
1204
		} else {
1205
			$old_gedcom = $this->gedcom;
1206
		}
1207
1208
		// First line of record may contain data - e.g. NOTE records.
1209
		list($new_gedcom) = explode("\n", $old_gedcom, 2);
1210
1211
		// Replacing (or deleting) an existing fact
1212
		foreach ($this->getFacts(null, false, Auth::PRIV_HIDE) as $fact) {
1213
			if (!$fact->isPendingDeletion()) {
1214
				if ($fact->getFactId() === $fact_id) {
1215
					if ($gedcom) {
1216
						$new_gedcom .= "\n" . $gedcom;
1217
					}
1218
					$fact_id = true; // Only replace/delete one copy of a duplicate fact
1219
				} elseif ($fact->getTag() != 'CHAN' || !$update_chan) {
1220
					$new_gedcom .= "\n" . $fact->getGedcom();
1221
				}
1222
			}
1223
		}
1224
		if ($update_chan) {
1225
			$new_gedcom .= "\n1 CHAN\n2 DATE " . strtoupper(date('d M Y')) . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
1226
		}
1227
1228
		// Adding a new fact
1229
		if (!$fact_id) {
1230
			$new_gedcom .= "\n" . $gedcom;
1231
		}
1232
1233
		if ($new_gedcom != $old_gedcom) {
1234
			// Save the changes
1235
			Database::prepare(
1236
				"INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, ?, ?)"
1237
			)->execute([
1238
				$this->tree->getTreeId(),
1239
				$this->xref,
1240
				$old_gedcom,
1241
				$new_gedcom,
1242
				Auth::id(),
1243
			]);
1244
1245
			$this->pending = $new_gedcom;
1246
1247 View Code Duplication
			if (Auth::user()->getPreference('auto_accept')) {
1248
				FunctionsImport::acceptAllChanges($this->xref, $this->tree->getTreeId());
1249
				$this->gedcom  = $new_gedcom;
1250
				$this->pending = null;
1251
			}
1252
		}
1253
		$this->parseFacts();
1254
	}
1255
1256
	/**
1257
	 * Update this record
1258
	 *
1259
	 * @param string $gedcom
1260
	 * @param bool   $update_chan
1261
	 */
1262
	public function updateRecord($gedcom, $update_chan) {
1263
		// MSDOS line endings will break things in horrible ways
1264
		$gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1265
		$gedcom = trim($gedcom);
1266
1267
		// Update the CHAN record
1268 View Code Duplication
		if ($update_chan) {
1269
			$gedcom = preg_replace('/\n1 CHAN(\n[2-9].*)*/', '', $gedcom);
1270
			$gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName();
1271
		}
1272
1273
		// Create a pending change
1274
		Database::prepare(
1275
			"INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, ?, ?)"
1276
		)->execute([
1277
			$this->tree->getTreeId(),
1278
			$this->xref,
1279
			$this->getGedcom(),
1280
			$gedcom,
1281
			Auth::id(),
1282
		]);
1283
1284
		// Clear the cache
1285
		$this->pending = $gedcom;
1286
1287
		// Accept this pending change
1288 View Code Duplication
		if (Auth::user()->getPreference('auto_accept')) {
1289
			FunctionsImport::acceptAllChanges($this->xref, $this->tree->getTreeId());
1290
			$this->gedcom  = $gedcom;
1291
			$this->pending = null;
1292
		}
1293
1294
		$this->parseFacts();
1295
1296
		Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref);
1297
	}
1298
1299
	/**
1300
	 * Delete this record
1301
	 */
1302
	public function deleteRecord() {
1303
		// Create a pending change
1304
		if (!$this->isPendingDeletion()) {
1305
			Database::prepare(
1306
				"INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, '', ?)"
1307
			)->execute([
1308
				$this->tree->getTreeId(),
1309
				$this->xref,
1310
				$this->getGedcom(),
1311
				Auth::id(),
1312
			]);
1313
		}
1314
1315
		// Auto-accept this pending change
1316
		if (Auth::user()->getPreference('auto_accept')) {
1317
			FunctionsImport::acceptAllChanges($this->xref, $this->tree->getTreeId());
1318
		}
1319
1320
		// Clear the cache
1321
		self::$gedcom_record_cache  = null;
0 ignored issues
show
Documentation Bug introduced by
It seems like null of type null is incompatible with the declared type array<integer,array<inte...ebtrees\GedcomRecord>>> of property $gedcom_record_cache.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
1322
		self::$pending_record_cache = null;
0 ignored issues
show
Documentation Bug introduced by
It seems like null of type null is incompatible with the declared type array<integer,array<integer,object<stdClass>>> of property $pending_record_cache.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
1323
1324
		Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref);
1325
	}
1326
1327
	/**
1328
	 * Remove all links from this record to $xref
1329
	 *
1330
	 * @param string $xref
1331
	 * @param bool   $update_chan
1332
	 */
1333
	public function removeLinks($xref, $update_chan) {
1334
		$value = '@' . $xref . '@';
1335
1336
		foreach ($this->getFacts() as $fact) {
1337
			if ($fact->getValue() === $value) {
1338
				$this->deleteFact($fact->getFactId(), $update_chan);
1339
			} elseif (preg_match_all('/\n(\d) ' . WT_REGEX_TAG . ' ' . $value . '/', $fact->getGedcom(), $matches, PREG_SET_ORDER)) {
1340
				$gedcom = $fact->getGedcom();
1341
				foreach ($matches as $match) {
1342
					$next_level  = $match[1] + 1;
1343
					$next_levels = '[' . $next_level . '-9]';
1344
					$gedcom      = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom);
1345
				}
1346
				$this->updateFact($fact->getFactId(), $gedcom, $update_chan);
1347
			}
1348
		}
1349
	}
1350
}
1351