Issues (1065)

other/update_timezones.php (12 issues)

1
<?php
2
3
/**
4
 * Simple Machines Forum (SMF)
5
 *
6
 * @package SMF
7
 * @author Simple Machines https://www.simplemachines.org
8
 * @copyright 2025 Simple Machines and individual contributors
9
 * @license https://www.simplemachines.org/about/smf/license.php BSD
10
 *
11
 * @version 2.1.6
12
 *
13
 * This is an internal development file. It should NOT be included in any SMF
14
 * distribution packages.
15
 *
16
 * This file exists to make it easier for devs to update Subs-Timezones.php and
17
 * Timezone.english.php when a new version of the IANA's time zone database is
18
 * released.
19
 *
20
 * Run this file from the command line in order to perform the update, then
21
 * review any changes manually before commiting.
22
 *
23
 * In particular, review the following:
24
 *
25
 * 1. If new $txt or $tztxt strings were added to the language file, check that
26
 *    they are spelled correctly and make sense.
27
 *
28
 * 2. If the TZDB added an entirely new time zone, a new chunk of fallback code
29
 *    will be added to get_tzid_fallbacks(), with an "ADD INFO HERE" comment
30
 *    above it.
31
 *
32
 *     - Replace "ADD INFO HERE" with something meaningful before commiting,
33
 *       such as a comment about when the new time zone was added to the TZDB
34
 *       and which existing time zone it diverged from. This info can be found
35
 *       at https://data.iana.org/time-zones/tzdb/NEWS.
36
 *
37
 * 3. When this script suggests a fallback tzid in the fallback code, it will
38
 *    insert an "OPTIONS" comment above that suggestion listing other tzids that
39
 *    could be used instead.
40
 *
41
 *     - If you like the automatically suggested tzid, just delete the comment.
42
 *
43
 *     - If you prefer one of the other options, change the suggested tzid to
44
 *       that other option, and then delete the comment.
45
 *
46
 *     - All "OPTIONS" comments should be removed before commiting.
47
 *
48
 * 4. Newly created time zones are also appended to their country's list in the
49
 *    get_sorted_tzids_for_country() function.
50
 *
51
 *     - Adjust the position of the new tzid in that list by comparing the
52
 *       city's population with the populations of the other listed cities.
53
 *       A quick Google or Wikipedia search is your friend here.
54
 *
55
 * 5. If a new "meta-zone" is required, new entries for it will be added to
56
 *    get_tzid_metazones() and to the $tztxt array in the language file.
57
 *
58
 *     - The new entry in get_tzid_metazones() will have an "OPTIONS" comment
59
 *       listing all the tzids in this new meta-zone. Feel free to use any of
60
 *       them as the representative tzid for the meta-zone. All "OPTIONS"
61
 *       comments should be removed before commiting.
62
 *
63
 *     - Also feel free to edit the $tztxt key for the new meta-zone. Just make
64
 *       sure to use the same key in both files.
65
 *
66
 *     - The value of the $tztxt string in the language file will probably need
67
 *       to be changed, because only a human can know what it should really be.
68
 */
69
70
define('SMF', 1);
71
define('SMF_USER_AGENT', 'SMF');
72
73
(new TimezoneUpdater())->execute();
74
75
/**
76
 * Updates SMF's time zone data.
77
 */
78
class TimezoneUpdater
79
{
80
	/*************************************************************************/
81
	// Settings
82
83
	/**
84
	 * Git tag of the earliest version of the TZDB to check against.
85
	 *
86
	 * This can be set to the TZDB version that was included in the earliest
87
	 * version of PHP that SMF supports, e.g. 2015g (a.k.a. 2015.7) for PHP 7.0.
88
	 * Leave blank to use the earliest release available.
89
	 */
90
	const TZDB_PREV_TAG = '2015g';
91
92
	/**
93
	 * Git tag of the most recent version of the TZDB to check against.
94
	 * Leave blank to use the latest release of the TZDB.
95
	 */
96
	const TZDB_CURR_TAG = '';
97
98
	/**
99
	 * URL where we can get a list of tagged releases of the TZDB.
100
	 */
101
	const TZDB_TAGS_URL = 'https://api.github.com/repos/eggert/tz/tags?per_page=1000';
102
103
	/**
104
	 * URL template to fetch raw data files for the TZDB
105
	 */
106
	const TZDB_FILE_URL = 'https://raw.githubusercontent.com/eggert/tz/{COMMIT}/{FILE}';
107
108
	/**
109
	 * URL where we can get nice English labels for tzids.
110
	 */
111
	const CLDR_TZNAMES_URL = 'https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-dates-full/main/en/timeZoneNames.json';
112
113
	/**
114
	 * Used in places where an earliest date is required.
115
	 *
116
	 * To support 32-bit PHP builds, use '1901-12-13 20:45:52 UTC'
117
	 */
118
	const DATE_MIN = '-292277022657-01-27 08:29:52 UTC';
119
120
	/**
121
	 * Used in places where a latest date is required.
122
	 */
123
	const DATE_MAX = 'January 1 + 2 years UTC';
124
125
	// End of settings
126
	/*************************************************************************/
127
128
	/**
129
	 * The path to the local SMF repo's working tree.
130
	 */
131
	public $boarddir;
132
133
	/**
134
	 * The path to the Sources directory.
135
	 */
136
	public $sourcedir;
137
138
	/**
139
	 * The path to the languages directory.
140
	 */
141
	public $langdir;
142
143
	/**
144
	 * Git commit hash associated with TZDB_PREV_TAG.
145
	 */
146
	public $prev_commit;
147
148
	/**
149
	 * Git commit hash associated with TZDB_CURR_TAG.
150
	 */
151
	public $curr_commit;
152
153
	/**
154
	 * This keeps track of whether any files actually changed.
155
	 */
156
	public $files_updated = false;
157
158
	/**
159
	 * Tags from the TZDB's GitHub repository.
160
	 */
161
	public $tzdb_tags = array();
162
163
	/**
164
	 * A multidimensional array of time zone identifiers,
165
	 * grouped into different information blocks.
166
	 */
167
	public $tz_data = array();
168
169
	/**
170
	 * Compiled information about all time zones in the TZDB.
171
	 */
172
	public $zones = array();
173
174
	/**
175
	 * Compiled information about all time zone transitions.
176
	 *
177
	 * This is similar to return value of PHP's timezone_transitions_get(),
178
	 * except that the array is built from the TZDB source as it existed at
179
	 * whatever version is defined as 'current' via self::TZDB_CURR_TAG.
180
	 */
181
	public $transitions;
182
183
	/**
184
	 * Info about any new meta-zones.
185
	 */
186
	public $new_metazones = array();
187
188
	/****************
189
	 * Public methods
190
	 ****************/
191
192
	/**
193
	 * Constructor.
194
	 */
195
	public function __construct()
196
	{
197
		$this->boarddir = realpath(dirname(__DIR__));
198
		$this->sourcedir = $this->boarddir . '/Sources';
199
		$this->langdir = $this->boarddir . '/Themes/default/languages';
200
201
		require_once($this->sourcedir . '/Subs.php');
202
203
		// Set some globals for the sake of functions in other files.
204
		$GLOBALS['boarddir'] = $this->boarddir;
205
		$GLOBALS['sourcedir'] = $this->sourcedir;
206
		$GLOBALS['langdir'] = $this->langdir;
207
		$GLOBALS['modSettings'] = array('default_timezone' => 'UTC');
208
		$GLOBALS['txt'] = array('etc' => 'etc.');
209
		$GLOBALS['tztxt'] = array();
210
	}
211
212
	/**
213
	 * Does the job.
214
	 */
215
	public function execute()
216
	{
217
		$this->fetch_tzdb_updates();
218
		$this->update_subs_timezones();
219
		$this->update_timezones_langfile();
220
221
		// Changed in unexpected ways?
222
		if (!empty($this->tz_data['changed']['wtf']))
223
		{
224
			echo 'The following time zones changed in unexpected ways. Please review them manually to figure out what to do.' . "\n\t" . implode("\n\t", $this->tz_data['changed']['wtf']) . "\n\n";
225
		}
226
227
		// Say something when finished.
228
		echo 'Done. ', $this->files_updated ? 'Please review all changes manually.' : 'No changes were made.', "\n";
229
	}
230
231
	/******************
232
	 * Internal methods
233
	 ******************/
234
235
	/**
236
	 * Builds an array of information about the time zone identifiers in
237
	 * two different versions of the TZDB, including information about
238
	 * what changed between the two.
239
	 *
240
	 * The data is saved in $this->tz_data.
241
	 */
242
	private function fetch_tzdb_updates(): void
243
	{
244
		$fetched = array();
245
246
		$this->fetch_tzdb_tags();
247
248
		foreach (array('prev', 'curr') as $build)
249
		{
250
			if ($build == 'prev')
251
			{
252
				$tag = isset($this->tzdb_tags[self::TZDB_PREV_TAG]) ? self::TZDB_PREV_TAG : array_key_first($this->tzdb_tags);
253
				$this->prev_commit = $this->tzdb_tags[$tag];
254
			}
255
			else
256
			{
257
				$tag = isset($this->tzdb_tags[self::TZDB_CURR_TAG]) ? self::TZDB_CURR_TAG : array_key_last($this->tzdb_tags);
258
				$this->curr_commit = $this->tzdb_tags[$tag];
259
			}
260
261
			$backzone_exists = $tag >= '2014g';
262
263
			list($fetched['zones'], $fetched['links']) = $this->get_primary_zones($this->tzdb_tags[$tag]);
264
265
			$fetched['backward_links'] = $this->get_backlinks($this->tzdb_tags[$tag]);
266
267
			list($fetched['backzones'], $fetched['backzone_links']) = $backzone_exists ? $this->get_backzones($this->tzdb_tags[$tag]) : array(array(), array());
268
269
			$this->tz_data[$build]['all'] = array_unique(array_merge(
270
				$fetched['zones'],
271
				array_keys($fetched['links']),
272
				array_values($fetched['links']),
273
				array_keys($fetched['backward_links']),
274
				array_values($fetched['backward_links']),
275
				array_keys($fetched['backzone_links']),
276
				array_values($fetched['backzone_links'])
277
			));
278
279
			$this->tz_data[$build]['links'] = array_merge(
280
				$fetched['backzone_links'],
281
				$fetched['backward_links'],
282
				$fetched['links']
283
			);
284
285
			$this->tz_data[$build]['canonical'] = array_diff(
286
				$this->tz_data[$build]['all'],
287
				array_keys($this->tz_data[$build]['links'])
288
			);
289
290
			$this->tz_data[$build]['backward_links'] = $fetched['backward_links'];
291
			$this->tz_data[$build]['backzones'] = $fetched['backzones'];
292
			$this->tz_data[$build]['backzone_links'] = $fetched['backzone_links'];
293
		}
294
295
		$this->tz_data['changed'] = array(
296
			'new' => array_diff($this->tz_data['curr']['all'], $this->tz_data['prev']['all']),
297
			'renames' => array(),
298
			'additions' => array(),
299
			'wtf' => array(),
300
		);
301
302
		// Figure out which new tzids are renames of old tzids.
303
		foreach ($this->tz_data['changed']['new'] as $tzid)
304
		{
305
			// Get any tzids that link to this one.
306
			$linked_tzids = array_keys($this->tz_data['curr']['links'], $tzid);
307
308
			// If this tzid is itself a link, get its target.
309
			if (isset($this->tz_data['curr']['links'][$tzid]))
310
				$linked_tzids[] = $this->tz_data['curr']['links'][$tzid];
311
312
			// No links, so skip.
313
			if (empty($linked_tzids))
314
				continue;
315
316
			$linked_tzids = array_unique($linked_tzids);
317
318
			// Try filtering out backzones in order to find to one unambiguous link.
319
			if (count($linked_tzids) > 1)
320
			{
321
				$not_backzones = array_diff($linked_tzids, $this->tz_data['curr']['backzones']);
322
323
				if (count($not_backzones) !== 1)
324
				{
325
					$this->tz_data['changed']['wtf'][] = $tzid;
326
					continue;
327
				}
328
329
				$linked_tzids = $not_backzones;
330
			}
331
332
			$this->tz_data['changed']['renames'][reset($linked_tzids)] = $tzid;
333
		}
334
335
		$this->tz_data['changed']['additions'] = array_diff(
336
			$this->tz_data['changed']['new'],
337
			$this->tz_data['changed']['renames'],
338
			$this->tz_data['changed']['wtf']
339
		);
340
	}
341
342
	/**
343
	 * Updates the contents of SMF's Subs-Timezones.php with any changes
344
	 * required to reflect changes in the TZDB.
345
	 *
346
	 * - Handles renames of tzids (e.g. Europe/Kiev -> Europe/Kyiv)
347
	 *   fully automatically.
348
	 *
349
	 * - If a new tzid has been created, adds fallback code for it in
350
	 *   get_tzid_fallbacks(), and appends it to the list of tzids for
351
	 *   its country in get_sorted_tzids_for_country().
352
	 *
353
	 * - Checks the rules defined in existing fallback code to make sure
354
	 *   they are still accurate, and updates any that are not. This is
355
	 *   necessary because new versions of the TZDB sometimes contain
356
	 *   corrections to previous data.
357
	 */
358
	private function update_subs_timezones(): void
359
	{
360
		$file_contents = file_get_contents($this->sourcedir . '/Subs-Timezones.php');
361
362
		// Handle any renames.
363
		foreach ($this->tz_data['changed']['renames'] as $old_tzid => $new_tzid)
364
		{
365
			// Rename it in get_tzid_metazones()
366
			if (!preg_match('~\n\h+\K\'' . $new_tzid . '\'(?=\s+=>\s+\'\w+\',)~', $file_contents))
367
			{
368
				$file_contents = preg_replace('~\n\h+\K\'' . $old_tzid . '\'(?=\s+=>\s+\'\w+\',)~', "'$new_tzid'", $file_contents);
369
370
				if (preg_match('~\n\h+\K\'' . $new_tzid . '\'(?=\s+=>\s+\'\w+\',)~', $file_contents))
371
				{
372
					echo "Renamed $old_tzid to $new_tzid in get_tzid_metazones().\n\n";
373
374
					$this->files_updated = true;
375
				}
376
			}
377
378
			// Rename it in get_sorted_tzids_for_country()
379
			if (!preg_match('~\n\h+\K\'' . $new_tzid . '\'(?=,\n)~', $file_contents))
380
			{
381
				$file_contents = preg_replace('~\n\h+\K\'' . $old_tzid . '\'(?=,\n)~', "'$new_tzid'", $file_contents);
382
383
				if (preg_match('~\n\h+\K\'' . $new_tzid . '\'(?=,\n)~', $file_contents))
384
				{
385
					echo "Renamed $old_tzid to $new_tzid in get_sorted_tzids_for_country().\n\n";
386
387
					$this->files_updated = true;
388
				}
389
			}
390
391
			// Ensure the fallback code is added.
392
			$insert_before = '(?=\n\h+// 2. Newly created time zones.)';
393
			$code = $this->generate_rename_fallback_code(array($old_tzid => $new_tzid));
394
395
			$search_for = preg_quote(substr(trim($code), 0, strpos(trim($code), "\n")), '~');
396
			$search_for = preg_replace('~\s+~', '\s+', $search_for);
397
398
			if (!preg_match('~' . $search_for . '~', $file_contents))
399
			{
400
				$file_contents = preg_replace('~' . $insert_before . '~', $code, $file_contents);
401
402
				if (preg_match('~' . $search_for . '~', $file_contents))
403
				{
404
					echo "Added fallback code for $new_tzid in get_tzid_fallbacks().\n\n";
405
406
					$this->files_updated = true;
407
				}
408
			}
409
		}
410
411
		// Insert fallback code for any additions.
412
		if (!empty($this->tz_data['changed']['additions']))
413
		{
414
			$fallbacks = $this->build_fallbacks();
415
416
			foreach ($this->tz_data['changed']['additions'] as $tzid)
417
			{
418
				// Ensure it is present in get_sorted_tzids_for_country()
419
				if (!preg_match('~\n\h+\K\'' . $tzid . '\'(?=,\n)~', $file_contents))
420
				{
421
					$cc = $this->get_cc_for_tzid($tzid, $this->curr_commit);
422
423
					$file_contents = preg_replace("~('$cc'\s*=>\s*array\((?:\s*'[^']+',)*\n)(\h*)(\),)~", '$1$2' . "\t'$tzid',\n" . '$2$3', $file_contents);
424
425
					if (preg_match('~\n\h+\K\'' . $tzid . '\'(?=,\n)~', $file_contents))
426
					{
427
						echo "Added $tzid to $cc in get_sorted_tzids_for_country().\n\n";
428
429
						$this->files_updated = true;
430
					}
431
				}
432
433
				// Ensure the fallback code is added.
434
				$insert_before = '(?=\s+\);\s+\$missing\s+=\s+)';
435
				$code = $this->generate_full_fallback_code(array($tzid => $fallbacks[$tzid]));
436
437
				$search_for = preg_quote(substr(trim($code), 0, strpos(trim($code), "\n")), '~');
438
				$search_for = preg_replace('~\s+~', '\s+', $search_for);
439
440
				// Not present at all.
441
				if (!preg_match('~' . $search_for . '~', $file_contents))
442
				{
443
					$file_contents = preg_replace('~' . $insert_before . '~', "\n\n\t\t// ADD INFO HERE\n" . rtrim($code), $file_contents, 1);
444
445
					if (preg_match('~' . $search_for . '~', $file_contents))
446
					{
447
						echo "Added fallback code for $tzid in get_tzid_fallbacks().\nACTION NEEDED: Review the fallback code for $tzid.\n\n";
448
449
						$this->files_updated = true;
450
					}
451
				}
452
				// Check whether our fallback rules are out of date.
453
				else
454
				{
455
					// First, parse the existing code into usable chunks.
456
					$search_for = str_replace('array\(', 'array(\((?'.'>[^()]|(?1))*\)),', $search_for);
457
458
					preg_match('~' . $search_for . '~', $file_contents, $matches);
459
460
					if (empty($matches[1]))
461
						continue;
462
463
					$existing_code = $matches[0];
464
					$existing_inner = $matches[1];
465
466
					preg_match_all('~(?:\h*//[^\n]*\n)*\h*array(\((?'.'>[^()]|(?1))*\)),~', $existing_inner, $matches);
467
					$existing_entries = $matches[0];
468
469
					// Now do the same with the generated code.
470
					preg_match('~' . $search_for . '~', $code, $matches);
471
					$new_inner = $matches[1];
472
473
					preg_match_all('~(?:\h*//[^\n]*\n)*\h*array(\((?'.'>[^()]|(?1))*\)),~', $new_inner, $matches);
474
					$new_entries = $matches[0];
475
476
					// This is what we will ultimately save.
477
					$final_entries = array();
478
					foreach ($new_entries as $new_entry_num => $new_entry)
479
					{
480
						if (strpos($new_entry, 'PHP_INT_MIN') !== false)
481
						{
482
							$final_entries[] = $new_entry;
483
							continue;
484
						}
485
486
						preg_match('~strtotime\(\'([^\']*)\'\)~', $new_entry, $m);
487
						$new_ts = $m[1];
488
489
						preg_match('~\'tzid\' => \'([^\']*)\'~', $new_entry, $m);
490
						$new_alt_tzid = $m[1];
491
492
						preg_match('~(//[^\n]*\n\h*)*(?=\'tzid\')~', $new_entry, $m);
493
						$new_alt_tzid_comment = $m[0];
494
495
						foreach ($existing_entries as $existing_entry_num => $existing_entry)
496
						{
497
							if (strpos($existing_entry, 'PHP_INT_MIN') !== false)
498
								continue;
499
500
							preg_match('~strtotime\(\'([^\']*)\'\)~', $existing_entry, $m);
501
							$existing_ts = $m[1];
502
503
							preg_match('~\'tzid\' => \'([^\']*)\'~', $existing_entry, $m);
504
							$existing_alt_tzid = $m[1];
505
506
							preg_match('~(//[^\n]*\n\h*)*(?=\'tzid\')~', $existing_entry, $m);
507
							$existing_alt_tzid_comment = $m[0];
508
509
							// Found an entry with the same timestamp.
510
							if ($existing_ts === $new_ts)
511
							{
512
								// Modify the existing entry rather than creating a new one.
513
								$final_entry = $existing_entry;
514
515
								// Do we need to change the tzid?
516
								if (strpos($new_alt_tzid_comment, $existing_alt_tzid) === false)
517
								{
518
									$final_entry = str_replace("'tzid' => '$existing_alt_tzid',", "'tzid' => '$new_alt_tzid',", $final_entry);
519
								}
520
521
								// Add or update the options comment.
522
								if (strpos($existing_alt_tzid_comment, '// OPTIONS: ') === false)
523
								{
524
									// Only insert options comment if we changed the tzid.
525
									if (strpos($new_alt_tzid_comment, $existing_alt_tzid) === false)
526
									{
527
										$final_entry = preg_replace("/'tzid' => '([^']*)',/", $new_alt_tzid_comment . "'tzid' => '$1',", $final_entry);
528
									}
529
								}
530
								else
531
								{
532
									$final_entry = preg_replace('~//\s*OPTIONS[^\n]+\n\h*~', $new_alt_tzid_comment, $final_entry);
533
								}
534
535
								$final_entries[] = $final_entry;
536
537
								continue 2;
538
							}
539
							// No existing entry has the same time stamp, so insert
540
							// a new entry at the correct position in the code.
541
							elseif (strtotime($existing_ts) > strtotime($new_ts))
542
							{
543
								$final_entries[] = $new_entry;
544
545
								continue 2;
546
							}
547
						}
548
					}
549
550
					$final_inner = "(\n" . implode("\n", $final_entries) . "\n\t\t)";
551
552
					if ($existing_inner !== $final_inner)
553
					{
554
						$final_code = str_replace($existing_inner, $final_inner, $existing_code);
555
						$file_contents = str_replace($existing_code, $final_code, $file_contents);
556
557
						$this->files_updated = true;
558
559
						echo "Fallback code for $tzid has been updated in get_tzid_fallbacks().\nACTION NEEDED: Review the fallback code for $tzid.\n\n";
560
					}
561
				}
562
			}
563
		}
564
565
		// Save the changes we've made so far.
566
		file_put_contents($this->sourcedir . '/Subs-Timezones.php', $file_contents);
567
568
		// Any new meta-zones to add?
569
		$file_contents = $this->update_metazones($file_contents);
570
571
		// Save the changes again.
572
		file_put_contents($this->sourcedir . '/Subs-Timezones.php', $file_contents);
573
	}
574
575
	/**
576
	 * This figures out if we need any new meta-zones. If we do, this (1) populates
577
	 * $this->new_metazones variable for use in update_language_file(), and
578
	 * (2) inserts the new meta-zones into $file_contents for Subs-Timezones.php.
579
	 *
580
	 * @param string $file_contents String content of Subs-Timezones.php.
581
	 * @return string Modified copy of $file_contents.
582
	 */
583
	private function update_metazones(string $file_contents): string
584
	{
585
		include($this->sourcedir . '/Subs-Timezones.php');
586
		include($this->langdir . '/Timezones.english.php');
587
588
		$metazones = get_tzid_metazones();
589
		$canonical_non_metazones = array_diff($this->tz_data['curr']['canonical'], array_keys($metazones));
590
591
		$this->build_zones();
592
593
		array_walk(
594
			$this->zones,
595
			function(&$zone)
596
			{
597
				unset($zone['new']);
598
			}
599
		);
600
601
		$this->build_timezone_transitions();
602
603
		$not_in_a_metazone = array();
604
605
		// Check for time zones that aren't covered by any existing metazone.
606
		// Go one year at a time to avoid false positives on places that simply
607
		// started or stopped using DST and that are covered by existing metazones
608
		// both before and after they changed their DST practices.
609
		for ($year = date_create(self::DATE_MAX . ' - 7 years')->format('Y'); $year <= date_create(self::DATE_MAX)->format('Y'); $year++)
610
		{
611
			$start_date = new \DateTimeImmutable($year . '-01-01T00:00:00+0000');
612
			$end_date = new \DateTimeImmutable(($year + 1) . '-01-01T00:00:00+0000');
613
614
			$timezones_when = array_keys(smf_list_timezones($start_date->getTimestamp()));
615
616
			$tzones = array();
617
			$tzones_loose = array();
618
619
			$not_in_a_metazone[$year] = array();
620
621
			foreach (array_merge(array_keys($metazones), $timezones_when, $canonical_non_metazones) as $tzid)
622
			{
623
				if (is_int($tzid))
624
					continue;
625
626
				$tzinfo = array();
627
				$tzinfo_loose = array();
628
629
				foreach ($this->transitions[$tzid] as $transition_num => $transition)
630
				{
631
					if ($this->transitions[$tzid][$transition_num]['ts'] > $end_date->getTimestamp())
632
					{
633
						continue;
634
					}
635
636
					if (isset($this->transitions[$tzid][$transition_num + 1]) && $this->transitions[$tzid][$transition_num + 1]['ts'] < $start_date->getTimestamp())
637
					{
638
						continue;
639
					}
640
641
					$this_transition = $this->transitions[$tzid][$transition_num];
642
643
					if ($this_transition['ts'] < $start_date->getTimestamp())
644
					{
645
						$this_transition['ts'] = $start_date->getTimestamp();
646
						$this_transition['time'] = $start_date->format('Y-m-d\TH:i:sO');
647
					}
648
649
					$tzinfo[] = $this_transition;
650
					$tzinfo_loose[] = array_diff_key($this_transition, array('ts' => 0, 'time' => 0));
651
				}
652
653
				$tzkey = serialize($tzinfo);
654
				$tzkey_loose = serialize($tzinfo_loose);
655
656
				if (!isset($tzones[$tzkey]))
657
				{
658
					// Don't bother with a new metazone if two places use all the same tzinfo except the clock switch is at a slightly different time (e.g. America/Moncton vs. America/Halifax in 2005)
659
					if (isset($tzones_loose[$tzkey_loose]))
660
					{
661
						$close_enough = true;
662
						$close_enough_hours = 3;
663
664
						foreach ($tzones_loose[$tzkey_loose] as $tzkey_similar)
665
						{
666
							$tzinfo_similar = unserialize($tzkey_similar);
667
668
							for ($i = 0; $i < count($tzinfo_similar); $i++)
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
669
							{
670
								$close_enough &= abs($tzinfo_similar[$i]['ts'] - $tzinfo[$i]['ts']) < 3600 * $close_enough_hours;
671
							}
672
						}
673
					}
674
675
					if (empty($close_enough) && in_array($tzid, $canonical_non_metazones))
676
					{
677
						if (($tzid === 'UTC' || strpos($tzid, '/') !== false) && strpos($tzid, 'Etc/') !== 0 && !in_array($tzid, $timezones_when))
678
						{
679
							$not_in_a_metazone[$year][$tzkey][] = $tzid;
680
						}
681
					}
682
					else
683
					{
684
						$tzones[$tzkey] = $tzid;
685
						$tzones_loose[$tzkey_loose][] = $tzkey;
686
					}
687
				}
688
			}
689
690
			// More filtering is needed.
691
			foreach ($not_in_a_metazone[$year] as $tzkey => $tzids)
692
			{
693
				// A metazone is not justified if it contains only one tzid.
694
				if (count($tzids) <= 1)
695
				{
696
					unset($not_in_a_metazone[$year][$tzkey]);
697
					continue;
698
				}
699
700
				// Even if no single existing metazone covers all of this set, maybe a combo of existing metazones do.
701
				$tzinfo = unserialize($tzkey);
702
703
				$tzid = reset($tzids);
704
705
				// Build a list of possible fallback zones for this zone.
706
				$possible_fallback_zones = $this->build_possible_fallback_zones($tzid);
707
708
				// Build a preliminary list of fallbacks.
709
				$fallbacks[$tzid] = array();
710
711
				$prev_fallback_tzid = '';
712
				foreach ($this->zones[$tzid]['entries'] as $entry_num => $entry)
713
				{
714
					if ($entry['format'] == '-00')
715
					{
716
						$prev_fallback_tzid = '';
717
						continue;
718
					}
719
720
					foreach ($this->find_fallbacks($possible_fallback_zones, $entry, $tzid, $prev_fallback_tzid, $not_in_a_metazone[$year]) as $fallback)
721
					{
722
						$prev_fallback_tzid = $fallback['tzid'];
723
						$fallbacks[$tzid][] = $fallback;
724
					}
725
				}
726
727
				$remove_earlier = false;
728
				for ($i = count($fallbacks[$tzid]) - 1; $i >= 0; $i--)
729
				{
730
					if ($fallbacks[$tzid][$i]['tzid'] === '')
731
						$remove_earlier = true;
732
733
					if ($remove_earlier)
734
					{
735
						unset($fallbacks[$tzid][$i]);
736
						continue;
737
					}
738
739
					$date_fallback = new DateTime($fallbacks[$tzid][$i]['ts']);
740
741
					if ($date_fallback->getTimestamp() > $end_date->getTimestamp())
742
						continue;
743
744
					if ($date_fallback->getTimestamp() < $start_date->getTimestamp())
745
					{
746
						$fallbacks[$tzid][$i]['ts'] = $start_date->format('Y-m-d\TH:i:sO');
747
						$remove_earlier = true;
748
					}
749
				}
750
751
				if (array_column($fallbacks[$tzid], 'ts') === array_column($tzinfo, 'time'))
752
					unset($not_in_a_metazone[$year][$tzkey]);
753
			}
754
755
			// If there's nothing left, move on.
756
			if (empty($not_in_a_metazone[$year]))
757
			{
758
				unset($not_in_a_metazone[$year]);
759
				continue;
760
			}
761
		}
762
763
		foreach ($not_in_a_metazone as $year => $possibly_should_become_metazone)
764
		{
765
			// Which tzids actually should be grouped into a metazone?
766
			foreach ($possibly_should_become_metazone as $tzkey => $tzids)
767
			{
768
				// If there's only one tzid, it doesn't need a new metazone.
769
				if (count($tzids) < 2)
770
					continue;
771
772
				// Sort for stability. Use get_sorted_tzids_for_country() data to guess
773
				// which tzid might be a good representative for the others.
774
				$sorted_tzids = array();
775
				foreach ($tzids as $tzid)
776
				{
777
					$cc = $this->get_cc_for_tzid($tzid, $this->curr_commit);
778
779
					if (isset($sorted_tzids[$cc]))
780
						continue;
781
782
					if (preg_match("~('$cc'\s*=>\s*array\((?:\s*'[^']+',)*\n)(\h*)(\),)~", $file_contents, $matches))
783
					{
784
						eval('$sorted_tzids = array_merge($sorted_tzids, array(' . $matches[0] . '));');
0 ignored issues
show
The use of eval() is discouraged.
Loading history...
785
					}
786
787
					$sorted_tzids[$cc] = array_intersect($sorted_tzids[$cc], $tzids);
788
				}
789
				ksort($sorted_tzids);
790
791
				$tzids = array();
792
793
				foreach ($sorted_tzids as $cc => $cc_tzids)
794
					$tzids = array_merge($tzids, $cc_tzids);
795
796
				// Now that we've sorted, set up the new metazone data.
797
				$tzid = reset($tzids);
798
799
				$this->new_metazones[implode(',', $tzids)] = array(
800
					'tzid' => $tzid,
801
					'options' => $tzids,
802
					'tztxt_key' => str_replace('/', '_', $tzid),
803
					// This one might change below.
804
					'uses_dst' => false,
805
				);
806
			}
807
		}
808
809
		// Do we need any new metazones?
810
		if (!empty($this->new_metazones))
811
		{
812
			// Any new metazones to create?
813
			preg_match('/\h*\$tzid_metazones\h*=\h*array\h*\([^)]*\);/', $file_contents, $matches);
814
			$existing_tzid_metazones_code = $matches[0];
815
816
			// Need some more info about this new metazone.
817
			foreach ($this->new_metazones as &$metazone)
818
			{
819
				$tzid = $metazone['tzid'];
820
821
				// Does it use DST?
822
				foreach ($this->transitions[$tzid] as $transition)
823
				{
824
					if (!empty($transition['isdst']))
825
					{
826
						$metazone['uses_dst'] = true;
827
						continue 2;
828
					}
829
				}
830
831
				// Metazones distinguish between North and South America.
832
				if (strpos($metazone['tztxt_key'], 'America_') === 0)
833
				{
834
					// Check the TZDB source file first.
835
					if ($this->zones[$tzid]['file'] === 'northamerica')
836
					{
837
						$metazone['tztxt_key'] = 'North_' . $metazone['tztxt_key'];
838
					}
839
					elseif ($this->zones[$tzid]['file'] === 'southamerica')
840
					{
841
						$metazone['tztxt_key'] = 'South_' . $metazone['tztxt_key'];
842
					}
843
					// If source was one of the backward or backzone files, guess based on latitude and/or country code.
844
					elseif ($this->zones[$tzid]['latitude'] > 13)
845
					{
846
						$metazone['tztxt_key'] = 'North_' . $metazone['tztxt_key'];
847
					}
848
					elseif ($this->zones[$tzid]['latitude'] > 7 && in_array($this->get_cc_for_tzid($tzid, $this->curr_commit), array('NI', 'CR', 'PA')))
849
					{
850
						$metazone['tztxt_key'] = 'North_' . $metazone['tztxt_key'];
851
					}
852
					else
853
					{
854
						$metazone['tztxt_key'] = 'South_' . $metazone['tztxt_key'];
855
					}
856
				}
857
			}
858
859
			$lines = explode("\n", $existing_tzid_metazones_code);
860
			$prev_line_number = 0;
861
			$added = array();
862
			foreach ($lines as $line_number => $line)
863
			{
864
				if (preg_match("~(\h*)'([\w/]+)'\h*=>\h*'\w+',~", $line, $matches))
865
				{
866
					$whitespace = $matches[1];
867
					$line_tzid = $matches[2];
868
869
					foreach ($this->new_metazones as $metazone)
870
					{
871
						$tzid = $metazone['tzid'];
872
873
						if (in_array($tzid, $added))
874
							continue;
875
876
						if ($tzid < $line_tzid)
877
						{
878
							$insertion = ($prev_line_number > 0 ? "\n" : '') . "\n" . $whitespace . '// ' . ($metazone['uses_dst'] ? 'Uses DST' : 'No DST');
879
880
							if (isset($metazone['options']))
881
							{
882
								$insertion .= "\n" . $whitespace . '// OPTIONS: ' . implode(', ', $metazone['options']);
883
							}
884
885
							$insertion .= "\n" . $whitespace . "'$tzid' => '" . $metazone['tztxt_key'] . "',";
886
887
							$lines[$prev_line_number] .= $insertion;
888
889
							$added[] = $tzid;
890
891
							echo "Created new metazone for $tzid in get_tzid_metazones().\n";
892
							echo "ACTION NEEDED: Review the automatically generated \$tztxt key, '" . $metazone['tztxt_key'] . "'.\n\n";
893
894
							$this->files_updated = true;
895
896
							if (count($added) === count($this->new_metazones))
897
								break 2;
898
						}
899
					}
900
901
					$prev_line_number = $line_number;
902
				}
903
			}
904
905
			$file_contents = str_replace($existing_tzid_metazones_code, implode("\n", $lines), $file_contents);
906
		}
907
908
		return $file_contents;
909
	}
910
911
	/**
912
	 * Updates the contents of SMF's Timezones.english.php with any
913
	 * changes required to reflect changes in the TZDB.
914
	 *
915
	 * - Handles renames of tzids (e.g. Europe/Kiev -> Europe/Kyiv)
916
	 *   fully automatically. For this situation, no further developer
917
	 *   work should be needed.
918
	 *
919
	 * - If a new tzid has been created, adds a new $txt string for it.
920
	 *   We try to fetch a label from the CLDR project, or generate a
921
	 *   preliminary label if the CLDR has not yet been updated to
922
	 *   include the new tzid.
923
	 *
924
	 * - Makes sure that $txt['iso3166'] is up to date, just in case a
925
	 *   new country has come into existence since the last update.
926
	 */
927
	private function update_timezones_langfile(): void
928
	{
929
		// Perform any renames.
930
		$file_contents = file_get_contents($this->langdir . '/Timezones.english.php');
931
932
		foreach ($this->tz_data['changed']['renames'] as $old_tzid => $new_tzid)
933
		{
934
			if (strpos($file_contents, "\$txt['$new_tzid']") === false)
935
			{
936
				$file_contents = str_replace("\$txt['$old_tzid']", "\$txt['$new_tzid']", $file_contents);
937
938
				if (strpos($file_contents, "\$txt['$new_tzid']") !== false)
939
				{
940
					echo "Renamed \$txt['$old_tzid'] to \$txt['$new_tzid'] in Timezones.english.php.\n\n";
941
942
					$this->files_updated = true;
943
				}
944
			}
945
		}
946
947
		// Get $txt and $tztxt as real variables so that we can work with them.
948
		eval(substr($file_contents, 5, -2));
0 ignored issues
show
The use of eval() is discouraged.
Loading history...
949
950
		// Add any new metazones.
951
		if (!empty($this->new_metazones))
952
		{
953
			foreach ($this->new_metazones as $metazone)
954
			{
955
				if (isset($tztxt[$metazone['tztxt_key']]))
956
					continue;
957
958
				// Get a label from the CLDR.
959
				list($label) = $this->get_tzid_label($metazone['tzid']);
960
961
				$label .= ' %1$s Time';
962
963
				$tztxt[$metazone['tztxt_key']] = $label;
964
965
				echo "Added \$tztxt['" . $metazone['tztxt_key'] . "'] to Timezones.english.php.\n";
966
				echo "ACTION NEEDED: Review the metazone label text, '$label'.\n\n";
967
968
				$this->files_updated = true;
969
			}
970
971
			// Sort the strings into our preferred order.
972
			uksort(
973
				$tztxt,
974
				function($a, $b)
975
				{
976
					$first = array('daylight_saving_time_false', 'daylight_saving_time_true', 'generic_timezone', 'GMT', 'UTC');
977
978
					if (in_array($a, $first) && !in_array($b, $first))
979
						return -1;
980
981
					if (!in_array($a, $first) && in_array($b, $first))
982
						return 1;
983
984
					if (in_array($a, $first) && in_array($b, $first))
985
						return array_search($a, $first) <=> array_search($b, $first);
986
987
					return $a <=> $b;
988
				}
989
			);
990
		}
991
992
		// Add any new tzids.
993
		$new_tzids = array_diff($this->tz_data['changed']['additions'], array_keys($txt));
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $txt seems to be never defined.
Loading history...
994
995
		if (!empty($new_tzids))
996
		{
997
			foreach ($new_tzids as $tzid)
998
			{
999
				$added_txt_msg = "Added \$txt['$tzid'] to Timezones.english.php.\n";
1000
1001
				// Get a label from the CLDR.
1002
				list($label, $msg) = $this->get_tzid_label($tzid);
1003
1004
				$txt[$tzid] = $label;
1005
1006
				$added_txt_msg .= $msg;
1007
1008
				// If this tzid is a new metazone, use the label for that, too.
1009
				if (isset($this->new_metazones[$tzid]))
1010
					$this->new_metazones[$tzid]['label'] = $label . ' %1$s Time';
1011
1012
				echo $added_txt_msg . "\n";
1013
				$this->files_updated = true;
1014
			}
1015
1016
			ksort($txt);
1017
		}
1018
1019
		// Ensure $txt['iso3166'] is up to date.
1020
		$iso3166_tab = $this->fetch_tzdb_file('iso3166.tab', $this->curr_commit);
1021
1022
		foreach (explode("\n", $iso3166_tab) as $line)
1023
		{
1024
			$line = trim(substr($line, 0, strcspn($line, '#')));
1025
1026
			if (empty($line))
1027
				continue;
1028
1029
			list($cc, $label) = explode("\t", $line);
1030
1031
			$label = strtr($label, array('&' => 'and', 'St ' => 'St. '));
1032
1033
			// Skip if already present.
1034
			if (isset($txt['iso3166'][$cc]))
1035
				continue;
1036
1037
			$txt['iso3166'][$cc] = $label;
1038
1039
			echo "Added \$txt['iso3166']['$cc'] to Timezones.english.php.\n\n";
1040
			$this->files_updated = true;
1041
		}
1042
1043
		ksort($txt['iso3166']);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $txt does not seem to be defined for all execution paths leading up to this point.
Loading history...
1044
1045
		// Rebuild the file content.
1046
		$lines = array(
1047
			'<' . '?php',
1048
			current(preg_grep('~^// Version:~', explode("\n", $file_contents))),
1049
			'',
1050
			'global $tztxt;',
1051
			'',
1052
		);
1053
1054
		foreach ($tztxt as $key => $value)
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $tztxt does not seem to be defined for all execution paths leading up to this point.
Loading history...
1055
		{
1056
			if ($key === 'daylight_saving_time_false')
1057
			{
1058
				$lines[] = '// Standard Time or Daylight Saving Time';
1059
			}
1060
			elseif ($key === 'generic_timezone')
1061
			{
1062
				$lines[] = '';
1063
				$lines[] = '// Labels for "meta-zones"';
1064
			}
1065
1066
			$value = addcslashes($value, "'");
1067
1068
			$lines[] = "\$tztxt['$key'] = '$value';";
1069
		}
1070
1071
		$lines[] = '';
1072
		$lines[] = '// Location names.';
1073
1074
		foreach ($txt as $key => $value)
1075
		{
1076
			if ($key === 'iso3166')
1077
				continue;
1078
1079
			$value = addcslashes($value, "'");
1080
1081
			$lines[] = "\$txt['$key'] = '$value';";
1082
		}
1083
1084
		$lines[] = '';
1085
		$lines[] = '// Countries';
1086
1087
		foreach ($txt['iso3166'] as $key => $value)
1088
		{
1089
			$value = addcslashes($value, "'");
1090
1091
			$lines[] = "\$txt['iso3166']['$key'] = '$value';";
1092
		}
1093
1094
		$lines[] = '';
1095
		$lines[] = '?>';
1096
1097
		// Save the changes.
1098
		file_put_contents($this->langdir . '/Timezones.english.php', implode("\n", $lines));
1099
	}
1100
1101
	/**
1102
	 * Returns a list of Git tags and the associated commit hashes for
1103
	 * each release of the TZDB available on GitHub.
1104
	 */
1105
	private function fetch_tzdb_tags(): void
1106
	{
1107
		foreach (json_decode(fetch_web_data(self::TZDB_TAGS_URL), true) as $tag)
0 ignored issues
show
It seems like fetch_web_data(self::TZDB_TAGS_URL) can also be of type false; however, parameter $json of json_decode() 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

1107
		foreach (json_decode(/** @scrutinizer ignore-type */ fetch_web_data(self::TZDB_TAGS_URL), true) as $tag)
Loading history...
1108
			$this->tzdb_tags[$tag['name']] = $tag['commit']['sha'];
1109
1110
		ksort($this->tzdb_tags);
1111
	}
1112
1113
	/**
1114
	 * Builds an array of canonical and linked time zone identifiers.
1115
	 *
1116
	 * Canoncial tzids are a simple list, while linked tzids are given
1117
	 * as 'link' => 'target' key-value pairs, where 'target' is a
1118
	 * canonical tzid and 'link' is a compatibility tzid that uses the
1119
	 * same time zone rules as its canonical target.
1120
	 *
1121
	 * @param string $commit Git commit hash of a specific TZDB version.
1122
	 * @return array Canonical and linked time zone identifiers.
1123
	 */
1124
	private function get_primary_zones(string $commit = 'main'): array
1125
	{
1126
		$canonical = array();
1127
		$links = array();
1128
1129
		$filenames = array(
1130
			'africa',
1131
			'antarctica',
1132
			'asia',
1133
			'australasia',
1134
			'etcetera',
1135
			'europe',
1136
			// 'factory',
1137
			'northamerica',
1138
			'southamerica',
1139
		);
1140
1141
		foreach ($filenames as $filename)
1142
		{
1143
			$file_contents = $this->fetch_tzdb_file($filename, $commit);
1144
1145
			foreach (explode("\n", $file_contents) as $line)
1146
			{
1147
				$line = trim(substr($line, 0, strcspn($line, '#')));
1148
1149
				if (strpos($line, 'Zone') !== 0 && strpos($line, 'Link') !== 0)
1150
					continue;
1151
1152
				$parts = array_values(array_filter(preg_split("~\h+~", $line)));
1153
1154
				if ($parts[0] === 'Zone')
1155
				{
1156
					$canonical[] = $parts[1];
1157
				}
1158
				elseif ($parts[0] === 'Link')
1159
				{
1160
					$links[$parts[2]] = $parts[1];
1161
				}
1162
			}
1163
		}
1164
1165
		return array($canonical, $links);
1166
	}
1167
1168
	/**
1169
	 * Builds an array of backward compatibility time zone identifiers.
1170
	 *
1171
	 * These supplement the linked tzids supplied by get_primary_zones()
1172
	 * and are formatted the same way (i.e. 'link' => 'target')
1173
	 *
1174
	 * @param string $commit Git commit hash of a specific TZDB version.
1175
	 * @return array Linked time zone identifiers.
1176
	 */
1177
	private function get_backlinks(string $commit): array
1178
	{
1179
		$backlinks = array();
1180
1181
		$file_contents = $this->fetch_tzdb_file('backward', $commit);
1182
1183
		foreach (explode("\n", $file_contents) as $line)
1184
		{
1185
			$line = trim(substr($line, 0, strcspn($line, '#')));
1186
1187
			if (strpos($line, "Link") !== 0)
1188
				continue;
1189
1190
			$parts = array_values(array_filter(preg_split("~\h+~", $line)));
1191
1192
			if (!isset($backlinks[$parts[2]]))
1193
				$backlinks[$parts[2]] = array();
1194
1195
			$backlinks[$parts[2]] = $parts[1];
1196
		}
1197
1198
		return $backlinks;
1199
	}
1200
1201
	/**
1202
	 * Similar to get_primary_zones() in all respects, except that it
1203
	 * returns the pre-1970 data contained in the TZDB's backzone file
1204
	 * rather than the main data files.
1205
	 *
1206
	 * @param string $commit Git commit hash of a specific TZDB version.
1207
	 * @return array Canonical and linked time zone identifiers.
1208
	 */
1209
	private function get_backzones(string $commit): array
1210
	{
1211
		$backzones = array();
1212
		$backzone_links = array();
1213
1214
		$file_contents = $this->fetch_tzdb_file('backzone', $commit);
1215
1216
		foreach (explode("\n", $file_contents) as $line)
1217
		{
1218
			$line = str_replace('#PACKRATLIST zone.tab ', '', $line);
1219
1220
			$line = trim(substr($line, 0, strcspn($line, '#')));
1221
1222
			if (strpos($line, "Zone") === 0)
1223
			{
1224
				$parts = array_values(array_filter(preg_split("~\h+~", $line)));
1225
				$backzones[] = $parts[1];
1226
			}
1227
			elseif (strpos($line, "Link") === 0)
1228
			{
1229
				$parts = array_values(array_filter(preg_split("~\h+~", $line)));
1230
				$backzone_links[$parts[2]] = $parts[1];
1231
			}
1232
		}
1233
1234
		$backzones = array_unique($backzones);
1235
		$backzone_links = array_unique($backzone_links);
1236
1237
		return array($backzones, $backzone_links);
1238
	}
1239
1240
	/**
1241
	 * Simply fetches the full contents of a file for the specified
1242
	 * version of the TZDB.
1243
	 *
1244
	 * @param string $filename File name.
1245
	 * @param string $commit Git commit hash of a specific TZDB version.
1246
	 * @return string The content of the file.
1247
	 */
1248
	private function fetch_tzdb_file(string $filename, string $commit): string
1249
	{
1250
		 static $files;
1251
1252
		 if (empty($files[$commit]))
1253
		 	$files[$commit] = array();
1254
1255
		 if (empty($files[$commit][$filename]))
1256
		 {
1257
		 	$files[$commit][$filename] = fetch_web_data(strtr(self::TZDB_FILE_URL, array('{COMMIT}' => $commit, '{FILE}' => $filename)));
1258
		 }
1259
1260
		 return $files[$commit][$filename];
1261
	}
1262
1263
	/**
1264
	 * Gets the ISO-3166 country code for a time zone identifier as
1265
	 * defined in the specified version of the TZDB.
1266
	 *
1267
	 * @param string $tzid A time zone identifier string.
1268
	 * @param string $commit Git commit hash of a specific TZDB version.
1269
	 * @return string A two-character country code, or '??' if not found.
1270
	 */
1271
	private function get_cc_for_tzid(string $tzid, string $commit): string
1272
	{
1273
		preg_match('~^(\w\w)\h+[+\-\d]+\h+' . $tzid . '~m', $this->fetch_tzdb_file('zone.tab', $commit), $matches);
1274
1275
		return isset($matches[1]) ? $matches[1] : '??';
1276
	}
1277
1278
	/**
1279
	 * Returns a nice English label for the given time zone identifier.
1280
	 *
1281
	 * @param string $tzid A time zone identifier.
1282
	 * @return array The label text, and possibly an "ACTION NEEDED" message.
1283
	 */
1284
	private function get_tzid_label(string $tzid): array
1285
	{
1286
		static $cldr_json;
1287
1288
		if (empty($cldr_json))
1289
			$cldr_json = json_decode(fetch_web_data(self::CLDR_TZNAMES_URL), true);
0 ignored issues
show
It seems like fetch_web_data(self::CLDR_TZNAMES_URL) can also be of type false; however, parameter $json of json_decode() 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

1289
			$cldr_json = json_decode(/** @scrutinizer ignore-type */ fetch_web_data(self::CLDR_TZNAMES_URL), true);
Loading history...
1290
1291
		$sub_array = $cldr_json['main']['en']['dates']['timeZoneNames']['zone'];
1292
1293
		$tzid_parts = explode('/', $tzid);
1294
1295
		foreach ($tzid_parts as $part)
1296
		{
1297
			if (!isset($sub_array[$part]))
1298
			{
1299
				$sub_array = array('exemplarCity' => false);
1300
				break;
1301
			}
1302
1303
			$sub_array = $sub_array[$part];
1304
		}
1305
1306
		$label = $sub_array['exemplarCity'];
1307
		$msg = '';
1308
1309
		// If tzid is not yet in the CLDR, make a preliminary label for now.
1310
		if ($label === false)
1311
		{
1312
			$label = str_replace(array('St_', '_'), array('St. ', ' '), substr($tzid, strrpos($tzid, '/') + 1));
1313
1314
			$msg = "ACTION NEEDED: Check that the label is spelled correctly, etc.\n";
1315
		}
1316
1317
		return array($label, $msg);
1318
	}
1319
1320
	/**
1321
	 * Builds fallback information for new time zones.
1322
	 *
1323
	 * @return array Fallback info for the new time zones.
1324
	 */
1325
	private function build_fallbacks(): array
1326
	{
1327
		$date_min = new \DateTime(self::DATE_MIN);
0 ignored issues
show
The assignment to $date_min is dead and can be removed.
Loading history...
1328
1329
		$this->build_zones();
1330
1331
		// See if we can find suitable fallbacks for each newly added zone.
1332
		$fallbacks = array();
1333
		foreach ($this->tz_data['changed']['additions'] as $tzid)
1334
		{
1335
			// Build a list of possible fallback zones for this zone.
1336
			$possible_fallback_zones = $this->build_possible_fallback_zones($tzid);
1337
1338
			// Build a preliminary list of fallbacks.
1339
			$fallbacks[$tzid] = array();
1340
1341
			$prev_fallback_tzid = '';
1342
			foreach ($this->zones[$tzid]['entries'] as $entry_num => $entry)
1343
			{
1344
				if ($entry['format'] == '-00')
1345
				{
1346
					$fallbacks[$tzid][] = array(
1347
						'ts' => 'PHP_INT_MIN',
1348
						'tzid' => '',
1349
					);
1350
1351
					$prev_fallback_tzid = '';
1352
1353
					continue;
1354
				}
1355
1356
				foreach ($this->find_fallbacks($possible_fallback_zones, $entry, $tzid, $prev_fallback_tzid, $this->tz_data['changed']['new']) as $fallback)
1357
				{
1358
					$prev_fallback_tzid = $fallback['tzid'];
1359
					$fallbacks[$tzid][] = $fallback;
1360
				}
1361
			}
1362
1363
			// Walk through the preliminary list and amalgamate any we can.
1364
			// Go in reverse order, because things tend to work out better that way.
1365
			$remove_earlier = false;
1366
			for ($i = count($fallbacks[$tzid]) - 1; $i > 0; $i--)
1367
			{
1368
				if ($fallbacks[$tzid][$i]['tzid'] === '')
1369
					$remove_earlier = true;
1370
1371
				if ($fallbacks[$tzid][$i]['ts'] === 'PHP_INT_MIN')
1372
				{
1373
					if (empty($fallbacks[$tzid][$i - 1]['tzid']))
1374
						$fallbacks[$tzid][$i - 1]['tzid'] = $fallbacks[$tzid][$i]['tzid'];
1375
1376
					$remove_earlier = true;
1377
				}
1378
1379
				if ($remove_earlier)
1380
				{
1381
					unset($fallbacks[$tzid][$i]);
1382
					continue;
1383
				}
1384
1385
				// If there are no options available, we can do nothing more.
1386
				if (empty($fallbacks[$tzid][$i]['options']) || empty($fallbacks[$tzid][$i - 1]['options']))
1387
				{
1388
					continue;
1389
				}
1390
1391
				// Which options work for both the current and previous entry?
1392
				$shared_options = array_intersect(
1393
					$fallbacks[$tzid][$i]['options'],
1394
					$fallbacks[$tzid][$i - 1]['options']
1395
				);
1396
1397
				// No shared options means we can't amalgamate these entries.
1398
				if (empty($shared_options))
1399
					continue;
1400
1401
				// We don't want canonical tzids unless absolutely necessary.
1402
				$temp = $shared_options;
1403
				foreach ($temp as $option)
1404
				{
1405
					if (isset($this->zones[$option]['canonical']))
1406
					{
1407
						// Filter out the canonical tzid.
1408
						$shared_options = array_filter(
1409
							$shared_options,
1410
							function ($tzid) use ($option)
1411
							{
1412
								return $tzid !== $this->zones[$option]['canonical'];
1413
							}
1414
						);
1415
1416
						// If top choice is the canonical tzid, replace it with the link.
1417
						// This check is probably redundant, but it doesn't hurt.
1418
						if ($fallbacks[$tzid][$i]['tzid'] === $this->zones[$option]['canonical'])
1419
							$fallbacks[$tzid][$i]['tzid'] = $option;
1420
1421
						if ($fallbacks[$tzid][$i - 1]['tzid'] === $this->zones[$option]['canonical'])
1422
							$fallbacks[$tzid][$i - 1]['tzid'] = $option;
1423
					}
1424
				}
1425
1426
				// If the previous entry's top choice isn't in the list of shared options,
1427
				// change it to one that is.
1428
				if (!empty($shared_options) && !in_array($fallbacks[$tzid][$i - 1]['tzid'], $shared_options))
1429
				{
1430
					$fallbacks[$tzid][$i - 1]['tzid'] = reset($shared_options);
1431
				}
1432
1433
				// Reduce the options for the previous entry down to only those that are
1434
				// in the current list of shared options.
1435
				$fallbacks[$tzid][$i - 1]['options'] = $shared_options;
1436
1437
				// We no longer need this one.
1438
				unset($fallbacks[$tzid][$i]);
1439
			}
1440
		}
1441
1442
		return $fallbacks;
1443
	}
1444
1445
	/**
1446
	 * Finds a viable fallback for an entry in a time zone's list of
1447
	 * transition rule changes. In some cases, the returned value will
1448
	 * consist of a series of fallbacks for different times during the
1449
	 * overall period of the entry.
1450
	 *
1451
	 * @param array $pfzs Array returned from build_possible_fallback_zones()
1452
	 * @param array $entry An element from $this->zones[$tzid]['entries']
1453
	 * @param string $tzid A time zone identifier
1454
	 * @param string $prev_fallback_tzid A time zone identifier
1455
	 * @param array $skip_tzids Tzids that should not be used as fallbacks.
1456
	 * @return array Fallback data for the entry.
1457
	 */
1458
	private function find_fallbacks(array $pfzs, array $entry, string $tzid, string $prev_fallback_tzid, array $skip_tzids): array
1459
	{
1460
		static $depth = 0;
1461
1462
		$fallbacks = array();
1463
1464
		unset($entry['from'], $entry['from_suffix'], $entry['until'], $entry['until_suffix']);
1465
1466
		$entry_id = md5(json_encode($entry));
1467
1468
		$date_min = new \DateTime(self::DATE_MIN);
1469
		$ts_min = $date_min->format('Y-m-d\TH:i:sO');
1470
1471
		$date_from = new \DateTime($entry['from_utc']);
1472
		$date_until = new \DateTime($entry['until_utc']);
1473
1474
		// Our first test should be the zone we used for the last one.
1475
		// This helps reduce unnecessary switching between zones.
1476
		$ordered_pfzs = $pfzs;
1477
		if (!empty($prev_fallback_tzid) && isset($pfzs[$prev_fallback_tzid]))
1478
		{
1479
			$prev_fallback_zone = $ordered_pfzs[$prev_fallback_tzid];
1480
1481
			unset($ordered_pfzs[$prev_fallback_tzid]);
1482
1483
			$ordered_pfzs = array_merge(array($prev_fallback_zone), $ordered_pfzs);
1484
		}
1485
1486
		$fallback_found = false;
1487
		$earliest_fallback_timestamp = strtotime('now');
1488
1489
		$i = 0;
1490
		while (!$fallback_found && $i < 50)
1491
		{
1492
			foreach ($ordered_pfzs as $pfz)
1493
			{
1494
				if (in_array($pfz['tzid'], $skip_tzids))
1495
					continue;
1496
1497
				if (isset($fallbacks[$entry_id]['options']) && in_array($pfz['tzid'], $fallbacks[$entry_id]['options']))
1498
				{
1499
					continue;
1500
				}
1501
1502
				foreach ($pfz['entries'] as $pfz_entry_num => $pfz_entry)
1503
				{
1504
					$pfz_date_from = new \DateTime($pfz_entry['from_utc']);
1505
					$pfz_date_until = new \DateTime($pfz_entry['until_utc']);
1506
1507
					// Offset and rules must match.
1508
					if ($entry['stdoff'] !== $pfz_entry['stdoff'])
1509
						continue;
1510
1511
					if ($entry['rules'] !== $pfz_entry['rules'])
1512
						continue;
1513
1514
					// Before the start of our range, so move on to the next entry.
1515
					if ($date_from->getTimestamp() >= $pfz_date_until->getTimestamp())
1516
						continue;
1517
1518
					// After the end of our range, so move on to the next possible fallback zone.
1519
					if ($date_from->getTimestamp() < $pfz_date_from->getTimestamp())
1520
					{
1521
						// Remember this in case we need to try again for transitions away from LMT.
1522
						$earliest_fallback_timestamp = min($earliest_fallback_timestamp, $pfz_date_from->getTimestamp());
1523
1524
						continue 2;
1525
					}
1526
1527
					// If this possible fallback ends before our existing options, skip it.
1528
					if (!empty($fallbacks[$entry_id]) && $pfz_date_until->getTimestamp() < $fallbacks[$entry_id]['end'])
1529
					{
1530
						continue;
1531
					}
1532
1533
					// At this point, we know we've found one.
1534
					$fallback_found = true;
1535
1536
					// If there is no fallback for this entry yet, create one.
1537
					if (empty($fallbacks[$entry_id]))
1538
					{
1539
						$fallbacks[$entry_id] = array(
1540
							'ts' => $date_from->format('Y-m-d\TH:i:sO'),
1541
							'end' => min($date_until->getTimestamp(), $pfz_date_until->getTimestamp()),
1542
							'tzid' => $pfz['tzid'],
1543
							'options' => array(),
1544
						);
1545
					}
1546
1547
					// Append to the list of options.
1548
					$fallbacks[$entry_id]['options'][] = $pfz['tzid'];
1549
1550
					if (isset($pfz['canonical']))
1551
						$fallbacks[$entry_id]['options'][] = $pfz['canonical'];
1552
1553
					if (isset($pfz['links']))
1554
					{
1555
						$fallbacks[$entry_id]['options'] = array_merge($fallbacks[$entry_id]['options'], $pfz['links']);
1556
					}
1557
1558
					// Only a partial overlap.
1559
					if ($date_until->getTimestamp() > $pfz_date_until->getTimestamp() && $depth < 10)
1560
					{
1561
						$depth++;
1562
1563
						$partial_entry = $entry;
1564
						$partial_entry['from_utc'] = $pfz_date_until->format('c');
1565
1566
						$fallbacks = array_merge($fallbacks, $this->find_fallbacks($pfzs, $partial_entry, $tzid, $pfz['tzid'], $skip_tzids));
1567
1568
						$depth--;
1569
					}
1570
1571
					break;
1572
				}
1573
			}
1574
1575
			if (!$fallback_found)
1576
			{
1577
				// If possible, move the timestamp forward and try again.
1578
				if ($date_from->format('Y-m-d\TH:i:sO') !== $ts_min && $date_from->getTimestamp() < $earliest_fallback_timestamp)
1579
				{
1580
					$fallbacks[] = array(
1581
						'ts' => $date_from->format('Y-m-d\TH:i:sO'),
1582
						'tzid' => '',
1583
						'options' => array(),
1584
					);
1585
1586
					$prev_fallback_tzid = '';
0 ignored issues
show
The assignment to $prev_fallback_tzid is dead and can be removed.
Loading history...
1587
1588
					$date_from->setTimestamp($earliest_fallback_timestamp);
1589
				}
1590
				// We've run out of options.
1591
				else
1592
				{
1593
					$fallbacks[$entry_id] = array(
1594
						'ts' => $date_from->format('Y-m-d\TH:i:sO'),
1595
						'tzid' => '',
1596
						'options' => array(),
1597
					);
1598
1599
					$fallback_found = true;
1600
				}
1601
			}
1602
		}
1603
1604
		foreach ($fallbacks as &$fallback)
1605
		{
1606
			$fallback['options'] = array_unique($fallback['options']);
1607
1608
			if ($fallback['ts'] <= $ts_min)
1609
				$fallback['ts'] = 'PHP_INT_MIN';
1610
		}
1611
1612
		return $fallbacks;
1613
	}
1614
1615
	/**
1616
	 * Compiles information about all time zones in the TZDB, including
1617
	 * transitions, location data, what other zones it links to or that
1618
	 * link to it, and whether it is new (where "new" means not present
1619
	 * in the earliest version of the TZDB that we are considering).
1620
	 */
1621
	private function build_zones(): void
1622
	{
1623
		if (!empty($this->zones))
1624
			return;
1625
1626
		$date_min = new \DateTime(self::DATE_MIN);
1627
		$date_max = new \DateTime(self::DATE_MAX);
1628
1629
		$links = array();
1630
1631
		$filenames = array(
1632
			'africa',
1633
			'antarctica',
1634
			'asia',
1635
			'australasia',
1636
			'etcetera',
1637
			'europe',
1638
			// 'factory',
1639
			'northamerica',
1640
			'southamerica',
1641
			'backward',
1642
			'backzone',
1643
		);
1644
1645
		// Populate $this->zones with TZDB data.
1646
		foreach ($filenames as $filename)
1647
		{
1648
			$tzid = '';
1649
1650
			foreach (explode("\n", $this->fetch_tzdb_file($filename, $this->curr_commit)) as $line_num => $line)
1651
			{
1652
				$line = rtrim(substr($line, 0, strcspn($line, '#')));
1653
1654
				if ($line === '')
1655
					continue;
1656
1657
				// Line starts a new zone record.
1658
				if (preg_match('/^Zone\h+(\w+(\/[\w+\-]+)*)/', $line, $matches))
1659
				{
1660
					$tzid = $matches[1];
1661
				}
1662
				// Line provides a link.
1663
				elseif (strpos($line, 'Link') === 0)
1664
				{
1665
					// No longer in a zone record.
1666
					$tzid = '';
1667
1668
					$parts = array_values(array_filter(preg_split("~\h+~", $line)));
1669
					$links[$parts[2]] = $parts[1];
1670
				}
1671
				// Line provides a rule.
1672
				elseif (strpos($line, 'Rule') === 0)
1673
				{
1674
					// No longer in a zone record.
1675
					$tzid = '';
1676
				}
1677
				// Line is not a continuation of the current zone record.
1678
				elseif (!empty($tzid) && !preg_match('/^\h+([+\-]?\d{1,2}:\d{2}|0\h+)/', $line))
1679
				{
1680
					$tzid = '';
1681
				}
1682
1683
				// If in a zone record, do stuff.
1684
				if (!empty($tzid))
1685
				{
1686
					$data = trim(preg_replace('/^Zone\h+\w+(\/[\w+\-]+)*\h+/', '', $line));
1687
1688
					$parts = array_combine(
1689
						array('stdoff', 'rules', 'format', 'until'),
1690
						array_pad(preg_split("~\h+~", $data, 4), 4, '')
1691
					);
1692
1693
					if (strpos($parts['stdoff'], ':') === false)
1694
						$parts['stdoff'] .= ':00';
1695
1696
					$this->zones[$tzid]['entries'][] = $parts;
1697
1698
					$this->zones[$tzid]['file'] = $filename;
1699
				}
1700
			}
1701
		}
1702
1703
		// Add a 'from' date to every entry of every zone.
1704
		foreach ($this->zones as $tzid => &$record)
1705
		{
1706
			$record['tzid'] = $tzid;
1707
1708
			foreach ($record['entries'] as $entry_num => &$entry)
1709
			{
1710
				// Until is when the current entry ends.
1711
				if (empty($entry['until']))
1712
				{
1713
					$entry['until'] = $date_max->format('Y-m-d\TH:i:s');
1714
					$entry['until_suffix'] = 'u';
1715
				}
1716
				else
1717
				{
1718
					// Rewrite date into PHP-parseable format.
1719
					$entry['until'] = $this->rewrite_date_string($entry['until']);
1720
1721
					// Find the suffix. Determines which zone the until timestamp is in.
1722
					preg_match('/\d+:\d+(|[wsugz])$/', $entry['until'], $matches);
1723
1724
					// Now set the until values.
1725
					if (!empty($matches[1]))
1726
					{
1727
						$entry['until_suffix'] = $matches[1];
1728
1729
						$entry['until'] = substr($entry['until'], 0, strrpos($entry['until'], $entry['until_suffix']));
1730
					}
1731
					else
1732
					{
1733
						$entry['until_suffix'] = '';
1734
					}
1735
1736
					$entry['until'] = date_format(new \DateTime($entry['until']), 'Y-m-d\TH:i:s');
1737
				}
1738
1739
				// From is just a copy of the previous entry's until.
1740
				if ($entry_num === 0)
1741
				{
1742
					$entry['from'] = $date_min->format('Y-m-d\TH:i:s');
1743
					$entry['from_suffix'] = 'u';
1744
				}
1745
				else
1746
				{
1747
					$entry['from'] = $record['entries'][$entry_num - 1]['until'];
1748
					$entry['from_suffix'] = $record['entries'][$entry_num - 1]['until_suffix'];
1749
				}
1750
			}
1751
		}
1752
1753
		// Set coordinates and country codes for each zone.
1754
		foreach (explode("\n", $this->fetch_tzdb_file('zone.tab', $this->curr_commit)) as $line_num => $line)
1755
		{
1756
			$line = rtrim(substr($line, 0, strcspn($line, '#')));
1757
1758
			if ($line === '')
1759
				continue;
1760
1761
			$parts = array_combine(
1762
				array('country_code', 'coordinates', 'tzid', 'comments'),
1763
				array_pad(preg_split("~\h~", $line, 4), 4, '')
1764
			);
1765
1766
			if (!isset($this->zones[$parts['tzid']]))
1767
				continue;
1768
1769
			$this->zones[$parts['tzid']]['country_code'] = $parts['country_code'];
1770
1771
			list($latitude, $longitude) = preg_split('/\b(?=[+\-])/', $parts['coordinates']);
1772
1773
			foreach (array('latitude', 'longitude') as $varname)
1774
			{
1775
				$deg_len = $varname === 'latitude' ? 3 : 4;
1776
1777
				$deg = substr($$varname, 0, $deg_len);
1778
				$min = substr($$varname, $deg_len, 2);
1779
				$sec = substr($$varname, $deg_len + 2);
1780
				$frac = (int) $min / 60 + (int) $sec / 3600;
1781
1782
				$this->zones[$parts['tzid']][$varname] = (float) $deg + $frac;
1783
			}
1784
		}
1785
1786
		// Ensure all zones have coordinates.
1787
		foreach ($this->zones as $tzid => &$record)
1788
		{
1789
			// The vast majority of zones.
1790
			if (isset($record['longitude']))
1791
				continue;
1792
1793
			// Etc/* can be given fake coordinates.
1794
			if (count($record['entries']) === 1)
1795
			{
1796
				$this->zones[$tzid]['latitude'] = 0;
1797
				$this->zones[$tzid]['longitude'] = (int) ($record['entries'][0]['stdoff']) * 15;
1798
			}
1799
1800
			// Still nothing? Must be a backzone that isn't in zone.tab.
1801
			// As of version 2022d, only case is Asia/Hanoi.
1802
			if (!isset($record['longitude']))
1803
				unset($this->zones[$tzid]);
1804
		}
1805
1806
		// From this point forward, handle links like canonical zones.
1807
		foreach ($links as $link_name => $target)
1808
		{
1809
			// Links can point to other links. We want the true canonical.
1810
			while (isset($links[$target]))
1811
				$target = $links[$target];
1812
1813
			if (!isset($this->zones[$link_name]))
1814
			{
1815
				$this->zones[$link_name] = $this->zones[$target];
1816
				$this->zones[$link_name]['tzid'] = $link_name;
1817
				unset($this->zones[$link_name]['links']);
1818
			}
1819
1820
			$this->zones[$link_name]['canonical'] = $target;
1821
			$this->zones[$target]['links'][] = $link_name;
1822
1823
			$this->zones[$target]['links'] = array_unique($this->zones[$target]['links']);
1824
		}
1825
1826
		// Mark new zones as such.
1827
		foreach ($this->tz_data['changed']['new'] as $tzid)
1828
		{
1829
			$this->zones[$tzid]['new'] = true;
1830
		}
1831
1832
		// Set UTC versions of every entry's 'from' and 'until' dates.
1833
		$this->build_timezone_transitions(true);
1834
	}
1835
1836
	/**
1837
	 * Populates $this->transitions with time zone transition information
1838
	 * similar to PHP's timezone_transitions_get(), except that the array
1839
	 * is built from the TZDB source as it existed at whatever version is
1840
	 * defined as 'current' via self::TZDB_CURR_TAG & $this->curr_commit.
1841
	 *
1842
	 * Also updates the entries for every tzid in $this->zones with
1843
	 * unambigous UTC timestamps for their start and end values.
1844
	 *
1845
	 * @param bool $rebuild If true, force a rebuild.
1846
	 */
1847
	private function build_timezone_transitions(bool $rebuild = false): void
1848
	{
1849
		static $zones_hash = '';
1850
1851
		if (md5(json_encode($this->zones)) !== $zones_hash)
1852
			$rebuild = true;
1853
1854
		$zones_hash = md5(json_encode($this->zones));
1855
1856
		if (!empty($this->transitions) && !$rebuild)
1857
			return;
1858
1859
		$utc = new \DateTimeZone('UTC');
1860
		$date_min = new \DateTime(self::DATE_MIN);
1861
		$date_max = new \DateTime(self::DATE_MAX);
1862
1863
		foreach ($this->zones as $tzid => &$zone)
1864
		{
1865
			// Shouldn't happen, but just in case...
1866
			if (empty($zone['entries']))
1867
				continue;
1868
1869
			$this->transitions[$tzid] = array();
1870
1871
			$zero = 0;
1872
			$prev_offset = 0;
1873
			$prev_std_offset = 0;
1874
			$prev_save = 0;
1875
			$prev_isdst = false;
1876
			$prev_abbr = '';
1877
			$prev_rules = '-';
1878
1879
			foreach ($zone['entries'] as $entry_num => $entry)
1880
			{
1881
				// Determine the standard time offset for this entry.
1882
				$stdoff_parts = array_map('intval', explode(':', $entry['stdoff']));
1883
				$stdoff_parts = array_pad($stdoff_parts, 3, 0);
1884
				$std_offset = abs($stdoff_parts[0]) * 3600 + $stdoff_parts[1] * 60 + $stdoff_parts[2];
1885
1886
				if (substr($entry['stdoff'], 0, 1) === '-')
1887
					$std_offset *= -1;
1888
1889
				// Entries never have gaps, so the end of one is the start of the next.
1890
				$entry_start = new \DateTime($entry['from'], $utc);
1891
				$entry_end = new \DateTime($entry['until'], $utc);
1892
1893
				$unadjusted_date_strings = array(
1894
					'entry_start' => $entry_start->format('Y-m-d\TH:i:s'),
1895
					'entry_end' => $entry_end->format('Y-m-d\TH:i:s'),
1896
				);
1897
1898
				switch ($entry['from_suffix'])
1899
				{
1900
					case 'u':
1901
					case 'g':
1902
					case 'z':
1903
						break;
1904
1905
					case 's':
1906
						$entry_start->setTimestamp($entry_start->getTimestamp() - $prev_std_offset);
1907
						break;
1908
1909
					default:
1910
						$entry_start->setTimestamp($entry_start->getTimestamp() - $prev_offset);
1911
						break;
1912
				}
1913
1914
				switch ($entry['until_suffix'])
1915
				{
1916
					case 'u':
1917
					case 'g':
1918
					case 'z':
1919
						$entry_end_offset_var = 'zero';
1920
						break;
1921
1922
					case 's':
1923
						$entry_end_offset_var = 'prev_std_offset';
1924
						break;
1925
1926
					default:
1927
						$entry_end_offset_var = 'prev_offset';
1928
						break;
1929
				}
1930
1931
				// For convenience elsewhere, provide UTC timestamps for the entry boundaries.
1932
				$zone['entries'][$entry_num]['from_utc'] = $entry_start->format('Y-m-d\TH:i:sO');
1933
1934
				if (isset($zone['entries'][$entry_num - 1]))
1935
				{
1936
					$zone['entries'][$entry_num - 1]['until_utc'] = $entry_start->format('Y-m-d\TH:i:sO');
1937
				}
1938
1939
1940
				// No DST rules.
1941
				if ($entry['rules'] == '-')
1942
				{
1943
					$ts = $entry_start->getTimestamp();
1944
					$time = $entry_start->format('Y-m-d\TH:i:sO');
1945
					$offset = $std_offset;
1946
					$isdst = false;
1947
					$abbr = $entry['format'] === '%z' ? sprintf("%+03d", strtr($offset, [':00' => '', ':' => ''])) : sprintf($entry['format'], 'S');
1948
					$save = 0;
1949
					$unadjusted_date_string = $unadjusted_date_strings['entry_start'];
1950
1951
					// Some abbr values use '+00/+01' instead of sprintf formats.
1952
					if (strpos($abbr, '/') !== false)
1953
						$abbr = substr($abbr, 0, strpos($abbr, '/'));
1954
1955
					// Skip if these values are identical to the previous values.
1956
					// ... with an exception for Europe/Lisbon, which is a special snowflake.
1957
					if ($offset === $prev_offset && $isdst === $prev_isdst && $abbr === $prev_abbr && $abbr !== 'LMT')
1958
					{
1959
						continue;
1960
					}
1961
1962
					$this->transitions[$tzid][$ts] = compact('ts', 'time', 'offset', 'isdst', 'abbr');
1963
1964
					$prev_offset = $offset;
1965
					$prev_std_offset = $std_offset;
1966
					$prev_save = $save == 0 ? 0 : $save / 3600 . ':' . sprintf('%02d', $save % 3600);
1967
					$prev_isdst = $isdst;
1968
					$prev_abbr = $abbr;
1969
					$entry_end_offset = $$entry_end_offset_var;
1970
				}
1971
				// Simple DST rules.
1972
				elseif (preg_match('/^-?\d+(:\d+)*$/', $entry['rules']))
1973
				{
1974
					$rules_parts = array_map('intval', explode(':', $entry['rules']));
1975
					$rules_parts = array_pad($rules_parts, 3, 0);
1976
					$rules_offset = abs($rules_parts[0]) * 3600 + $rules_parts[1] * 60 + $rules_parts[2];
1977
1978
					if (substr($entry['rules'], 0, 1) === '-')
1979
						$rules_offset *= -1;
1980
1981
					$ts = $entry_start->getTimestamp();
1982
					$time = $entry_start->format('Y-m-d\TH:i:sO');
1983
					$offset = $std_offset + $rules_offset;
1984
					$isdst = true;
1985
					$abbr = $entry['format'] === '%z' ? sprintf("%+03d", strtr($offset, [':00' => '', ':' => ''])) : sprintf($entry['format'], 'D');
1986
					$save = $rules_offset;
1987
					$unadjusted_date_string = $unadjusted_date_strings['entry_start'];
1988
1989
					// Some abbr values use '+00/+01' instead of sprintf formats.
1990
					if (strpos($abbr, '/') !== false)
1991
						$abbr = substr($abbr, strpos($abbr, '/'));
1992
1993
					// Skip if these values are identical to the previous values.
1994
					if ($offset === $prev_offset && $isdst === $prev_isdst && $abbr === $prev_abbr)
1995
						continue;
1996
1997
					$this->transitions[$tzid][$ts] = compact('ts', 'time', 'offset', 'isdst', 'abbr');
1998
1999
					$prev_offset = $offset;
2000
					$prev_std_offset = $std_offset;
2001
					$prev_save = $save == 0 ? 0 : $save / 3600 . ':' . sprintf('%02d', $save % 3600);
2002
					$prev_isdst = $isdst;
2003
					$prev_abbr = $abbr;
2004
					$entry_end_offset = $$entry_end_offset_var;
2005
				}
2006
				// Complex DST rules
2007
				else
2008
				{
2009
					$default_letter = '-';
2010
					$default_save = 0;
2011
2012
					$rule_transitions = $this->get_applicable_rule_transitions($entry['rules'], $unadjusted_date_strings, (int) $std_offset, (string) $prev_save);
2013
2014
					// Figure out the state when the entry starts.
2015
					foreach ($rule_transitions as $date_string => $info)
2016
					{
2017
						if ($date_string >= $unadjusted_date_strings['entry_start'])
2018
							break;
2019
2020
						$default_letter = $info['letter'];
2021
						$default_save = $info['save'];
2022
2023
						if ($std_offset === $prev_std_offset && $prev_rules === $entry['rules'])
2024
						{
2025
							$prev_save = $info['save'];
2026
2027
							if ($prev_save != 0)
2028
							{
2029
								$prev_save_parts = array_map('intval', explode(':', $prev_save));
2030
								$prev_save_parts = array_pad($prev_save_parts, 3, 0);
2031
								$prev_save_offset = abs($prev_save_parts[0]) * 3600 + $prev_save_parts[1] * 60 + $prev_save_parts[2];
2032
2033
								if (substr($prev_save, 0, 1) === '-')
2034
									$prev_save_offset *= -1;
2035
							}
2036
							else
2037
							{
2038
								$prev_save_offset = 0;
2039
							}
2040
2041
							$prev_offset = $prev_std_offset + $prev_save_offset;
2042
						}
2043
2044
						unset($rule_transitions[$date_string]);
2045
					}
2046
2047
					// Add a rule transition at entry start, if not already present.
2048
					if (!in_array($unadjusted_date_strings['entry_start'], array_column($rule_transitions, 'unadjusted_date_string')))
2049
					{
2050
						if ($default_letter === '-')
2051
						{
2052
							foreach ($rule_transitions as $date_string => $info)
2053
							{
2054
								if ($info['save'] == $default_save)
2055
								{
2056
									$default_letter = $info['letter'];
2057
									break;
2058
								}
2059
							}
2060
						}
2061
2062
						$rule_transitions[$unadjusted_date_strings['entry_start']] = array(
2063
							'letter' => $default_letter,
2064
							'save' => $default_save,
2065
							'at_suffix' => $entry['from_suffix'],
2066
							'unadjusted_date_string' => $unadjusted_date_strings['entry_start'],
2067
							'adjusted_date_string' => $entry_start->format('Y-m-d\TH:i:sO'),
2068
						);
2069
2070
						ksort($rule_transitions);
2071
					}
2072
					// Ensure entry start rule transition uses correct UTC time.
2073
					else
2074
					{
2075
						$rule_transitions[$unadjusted_date_strings['entry_start']]['adjusted_date_string'] = $entry_start->format('Y-m-d\TH:i:sO');
2076
					}
2077
2078
					// Create the transitions
2079
					foreach ($rule_transitions as $date_string => $info)
2080
					{
2081
						if (!empty($info['adjusted_date_string']))
2082
						{
2083
							$transition_date = new \DateTime($info['adjusted_date_string']);
2084
						}
2085
						else
2086
						{
2087
							$transition_date = new \DateTime($date_string, $utc);
2088
2089
							if (empty($info['at_suffix']) || $info['at_suffix'] === 'w')
2090
							{
2091
								$transition_date->setTimestamp($transition_date->getTimestamp() - $prev_offset);
2092
							}
2093
							elseif ($info['at_suffix'] === 's')
2094
							{
2095
								$transition_date->setTimestamp($transition_date->getTimestamp() - $prev_std_offset);
2096
							}
2097
						}
2098
2099
						$save_parts = array_map('intval', explode(':', $info['save']));
2100
						$save_parts = array_pad($save_parts, 3, 0);
2101
						$save_offset = abs($save_parts[0]) * 3600 + $save_parts[1] * 60 + $save_parts[2];
2102
2103
						if (substr($info['save'], 0, 1) === '-')
2104
							$save_offset *= -1;
2105
2106
						// Populate the transition values.
2107
						$ts = $transition_date->getTimestamp();
2108
						$time = $transition_date->format('Y-m-d\TH:i:sO');
2109
						$offset = $std_offset + $save_offset;
2110
						$isdst = $save_offset != 0;
2111
						$abbr = $entry['format'] === '%z' ? sprintf("%+03d", strtr($offset, [':00' => '', ':' => ''])) : (sprintf($entry['format'], $info['letter'] === '-' ? '' : $info['letter']));
2112
						$save = $save_offset;
2113
						$unadjusted_date_string = $info['unadjusted_date_string'];
2114
2115
						// Some abbr values use '+00/+01' instead of sprintf formats.
2116
						if (strpos($abbr, '/') !== false)
2117
						{
2118
							$abbrs = explode('/', $abbr);
2119
							$abbr = $isdst ? $abbrs[1] : $abbrs[0];
2120
						}
2121
2122
						// Skip if these values are identical to the previous values.
2123
						if ($offset === $prev_offset && $isdst === $prev_isdst && $abbr === $prev_abbr)
2124
							continue;
2125
2126
						// Don't create a redundant transition for the entry's end.
2127
						if ($ts >= $entry_end->getTimestamp() - $$entry_end_offset_var)
2128
							break;
2129
2130
						// Remember for the next iteration.
2131
						$prev_offset = $offset;
2132
						$prev_std_offset = $std_offset;
2133
						$prev_save = $save == 0 ? 0 : $save / 3600 . ':' . sprintf('%02d', $save % 3600);
2134
						$prev_isdst = $isdst;
2135
						$prev_abbr = $abbr;
2136
						$entry_end_offset = $$entry_end_offset_var;
2137
2138
						// This can happen in some rare cases.
2139
						if ($ts < $entry_start->getTimestamp())
2140
						{
2141
							// Update the transition for the entry start, if it exists.
2142
							if (isset($this->transitions[$tzid][$entry_start->getTimestamp()]))
2143
							{
2144
								$this->transitions[$tzid][$entry_start->getTimestamp()] = array_merge(
2145
									$this->transitions[$tzid][$entry_start->getTimestamp()],
2146
									compact('offset', 'isdst', 'abbr')
2147
								);
2148
							}
2149
2150
							continue;
2151
						}
2152
2153
						// Create the new transition.
2154
						$this->transitions[$tzid][$ts] = compact('ts', 'time', 'offset', 'isdst', 'abbr');
2155
					}
2156
				}
2157
2158
				if (!empty($entry_end_offset))
2159
					$entry_end->setTimestamp($entry_end->getTimestamp() - $entry_end_offset);
2160
2161
				$prev_rules = $entry['rules'];
2162
			}
2163
2164
			// Ensure the transitions are in the correct chronological order
2165
			ksort($this->transitions[$tzid]);
2166
2167
			// Work around a data error in versions 2021b - 2022c of the TZDB.
2168
			if ($tzid === 'Africa/Freetown')
2169
			{
2170
				$last_transition = end($this->transitions[$tzid]);
2171
2172
				if ($last_transition['time'] === '1941-12-07T01:00:00+0000' && $last_transition['abbr'] === '+01')
2173
				{
2174
					$this->transitions[$tzid][$last_transition['ts']] = array_merge(
2175
						$last_transition,
2176
						array(
2177
							'offset' => 0,
2178
							'isdst' => false,
2179
							'abbr' => 'GMT',
2180
						)
2181
					);
2182
				}
2183
			}
2184
2185
			// Use numeric keys.
2186
			$this->transitions[$tzid] = array_values($this->transitions[$tzid]);
2187
2188
			// Give the final entry an 'until_utc' date.
2189
			$zone['entries'][$entry_num]['until_utc'] = self::DATE_MAX;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $entry_num does not seem to be defined for all execution paths leading up to this point.
Loading history...
2190
		}
2191
	}
2192
2193
	/**
2194
	 * Identifies time zones that might work as fallbacks for a given tzid.
2195
	 *
2196
	 * @param array $new_tzid A time zone identifier
2197
	 * @return array A subset of $this->zones that might work as fallbacks for $new_tzid
2198
	 */
2199
	private function build_possible_fallback_zones($new_tzid): array
2200
	{
2201
		$new_zone = $this->zones[$new_tzid];
2202
2203
		// Build a list of possible fallback zones to check for this zone.
2204
		$possible_fallback_zones = $this->zones;
2205
2206
		// Filter and sort $possible_fallback_zones.
2207
		// We do this for performance purposes, because we are more likely to find
2208
		// a suitable fallback nearby than far away.
2209
		foreach ($possible_fallback_zones as $tzid => $record)
2210
		{
2211
			// Obviously the new ones can't be fallbacks. That's the whole point of
2212
			// this exercise, after all.
2213
			if (!empty($record['new']))
2214
			{
2215
				unset($possible_fallback_zones[$tzid]);
2216
				continue;
2217
			}
2218
2219
			// Obviously won't work if it's on the other side of the planet.
2220
			$possible_fallback_zones[$tzid]['distance'] = $this->get_distance_from($possible_fallback_zones[$tzid], $this->zones[$new_tzid]);
2221
2222
			if ($possible_fallback_zones[$tzid]['distance'] > 6 * 15)
2223
			{
2224
				unset($possible_fallback_zones[$tzid]);
2225
				continue;
2226
			}
2227
		}
2228
2229
		// Rank the possible fallbacks so that the (probably) best one is first.
2230
		// A human should still check our suggestion, though.
2231
		uasort(
2232
			$possible_fallback_zones,
2233
			function ($a, $b) use ($new_zone)
2234
			{
2235
				$cc = $new_zone['country_code'];
2236
2237
				if (!isset($a['country_code']))
2238
					$a['country_code'] = 'ZZ';
2239
2240
				if (!isset($b['country_code']))
2241
					$b['country_code'] = 'ZZ';
2242
2243
				// Prefer zones in the same country.
2244
				if ($a['country_code'] === $cc && $b['country_code'] !== $cc)
2245
					return -1;
2246
2247
				if ($a['country_code'] !== $cc && $b['country_code'] === $cc)
2248
					return 1;
2249
2250
				// Legacy zones make good fallbacks, because they are rarely used.
2251
				if ($a['country_code'] === 'ZZ' && $b['country_code'] !== 'ZZ')
2252
					return -1;
2253
2254
				if ($a['country_code'] !== 'ZZ' && $b['country_code'] === 'ZZ')
2255
					return 1;
2256
2257
				if (strpos($a['tzid'], '/') === false && strpos($b['tzid'], '/') !== false)
2258
					return -1;
2259
2260
				if (strpos($a['tzid'], '/') !== false && strpos($b['tzid'], '/') === false)
2261
					return 1;
2262
2263
				// Prefer links over canonical zones.
2264
				if (isset($a['canonical']) && !isset($b['canonical']))
2265
					return -1;
2266
2267
				if (!isset($a['canonical']) && isset($b['canonical']))
2268
					return 1;
2269
2270
				// Prefer nearby zones over distant zones.
2271
				if ($a['distance'] > $b['distance'])
2272
					return 1;
2273
2274
				if ($a['distance'] < $b['distance'])
2275
					return -1;
2276
2277
				// This is unlikely, but as a last resort use alphabetical sorting.
2278
				return $a['tzid'] > $b['tzid'] ? 1 : -1;
2279
			}
2280
		);
2281
2282
		// Obviously, a time zone can't fall back to itself.
2283
		unset($possible_fallback_zones[$new_tzid]);
2284
2285
		return $possible_fallback_zones;
2286
	}
2287
2288
	/**
2289
	 * Gets rule-based transitions for a time zone entry.
2290
	 *
2291
	 * @param string $rule_name The name of a time zone rule.
2292
	 * @param array $unadjusted_date_strings Dates for $entry_start and $entry_end.
2293
	 * @param int $std_offset The standard time offset for this time zone entry.
2294
	 * @param string $prev_save The daylight saving value that applied just before $entry_start.
2295
	 * @return array Transition rules.
2296
	 */
2297
	private function get_applicable_rule_transitions(string $rule_name, array $unadjusted_date_strings, int $std_offset, string $prev_save): array
2298
	{
2299
		static $rule_transitions = array();
2300
2301
		$utc = new \DateTimeZone('UTC');
2302
		$date_max = new \DateTime(self::DATE_MAX);
2303
2304
		if (!isset($rule_transitions[$rule_name]))
2305
		{
2306
			$rules = $this->get_rules();
2307
2308
			foreach ($rules[$rule_name] as $rule_num => $rule)
2309
			{
2310
				preg_match('/(\d+(?::\d+)*)([wsugz]|)$/', $rule['at'], $matches);
2311
				$rule['at'] = $matches[1];
2312
				$rule['at_suffix'] = $matches[2];
2313
2314
				$year_from = $rule['from'];
2315
2316
				if ($rule['to'] === 'max')
2317
				{
2318
					$year_to = $date_max->format('Y');
2319
				}
2320
				elseif ($rule['to'] === 'only')
2321
				{
2322
					$year_to = $year_from;
2323
				}
2324
				else
2325
					$year_to = $rule['to'];
2326
2327
				for ($year = $year_from; $year <= $year_to; $year++)
2328
				{
2329
					$transition_date_string = $this->rewrite_date_string(
2330
						implode(' ', array(
2331
							$year,
2332
							$rule['in'],
2333
							$rule['on'],
2334
							$rule['at'] . (strpos($rule['at'], ':') === false ? ':00' : ''),
2335
						))
2336
					);
2337
2338
					$transition_date = new \DateTime($transition_date_string, $utc);
2339
2340
					$rule_transitions[$rule_name][$transition_date->format('Y-m-d\TH:i:s')] = array(
2341
						'letter' => $rule['letter'],
2342
						'save' => $rule['save'],
2343
						'at_suffix' => $rule['at_suffix'],
2344
						'unadjusted_date_string' => $transition_date->format('Y-m-d\TH:i:s'),
2345
					);
2346
				}
2347
			}
2348
2349
			$temp = array();
2350
			foreach ($rule_transitions[$rule_name] as $date_string => $info)
2351
			{
2352
				if (!empty($info['at_suffix']) && $info['at_suffix'] !== 'w')
2353
				{
2354
					$temp[$date_string] = $info;
2355
					$prev_save = $info['save'];
2356
					continue;
2357
				}
2358
2359
				$transition_date = new \DateTime($date_string, $utc);
2360
2361
				$save_parts = array_map('intval', explode(':', $prev_save));
2362
				$save_parts = array_pad($save_parts, 3, 0);
2363
				$save_offset = abs($save_parts[0]) * 3600 + $save_parts[1] * 60 + $save_parts[2];
2364
2365
				if (substr($prev_save, 0, 1) === '-')
2366
					$save_offset *= -1;
2367
2368
				$temp[$transition_date->format('Y-m-d\TH:i:s')] = $info;
2369
				$prev_save = $info['save'];
2370
			}
2371
			$rule_transitions[$rule_name] = $temp;
2372
2373
			ksort($rule_transitions[$rule_name]);
2374
		}
2375
2376
		$applicable_transitions = array();
2377
2378
		foreach ($rule_transitions[$rule_name] as $date_string => $info)
2379
		{
2380
			// After end of entry, so discard it.
2381
			if ($date_string > $unadjusted_date_strings['entry_end'])
2382
				continue;
2383
2384
			// Keep exactly one that preceeds the start of the entry,
2385
			// so that we can know the state at the start of the entry.
2386
			if ($date_string < $unadjusted_date_strings['entry_start'])
2387
				array_shift($applicable_transitions);
2388
2389
			$applicable_transitions[$date_string] = $info;
2390
		}
2391
2392
		return $applicable_transitions;
2393
	}
2394
2395
	/**
2396
	 * Compiles all the daylight saving rules in the TZDB.
2397
	 *
2398
	 * @return array Compiled rules, indexed by rule name.
2399
	 */
2400
	private function get_rules(): array
2401
	{
2402
		static $rules = array();
2403
2404
		if (!empty($rules))
2405
			return $rules;
2406
2407
		$filenames = array(
2408
			'africa',
2409
			'antarctica',
2410
			'asia',
2411
			'australasia',
2412
			'etcetera',
2413
			'europe',
2414
			'northamerica',
2415
			'southamerica',
2416
			'backward',
2417
			'backzone',
2418
		);
2419
2420
		// Populate $rules with TZDB data.
2421
		foreach ($filenames as $filename)
2422
		{
2423
			$tzid = '';
0 ignored issues
show
The assignment to $tzid is dead and can be removed.
Loading history...
2424
2425
			foreach (explode("\n", $this->fetch_tzdb_file($filename, $this->curr_commit)) as $line_num => $line)
2426
			{
2427
				$line = rtrim(substr($line, 0, strcspn($line, '#')));
2428
2429
				if ($line === '')
2430
					continue;
2431
2432
				if (strpos($line, 'Rule') === 0)
2433
				{
2434
					if (strpos($line, '"') !== false)
2435
					{
2436
						preg_match_all('/"[^"]*"/', $line, $matches);
2437
2438
						$patterns = array();
2439
						$replacements = array();
2440
						foreach ($matches[0] as $key => $value)
2441
						{
2442
							$patterns[$key] = '/' . preg_quote($value, '/') . '/';
2443
							$replacements[$key] = md5($value);
2444
						}
2445
2446
						$line = preg_replace($patterns, $replacements, $line);
2447
2448
						$parts = preg_split('/\h+/', $line);
2449
2450
						foreach ($parts as &$part)
2451
						{
2452
							$r_keys = array_keys($replacements, $part);
2453
2454
							if (!empty($r_keys))
2455
								$part = $matches[0][$r_keys[0]];
2456
						}
2457
					}
2458
					else
2459
						$parts = preg_split('/\h+/', $line);
2460
2461
					$parts = array_combine(array('rule', 'name', 'from', 'to', 'type', 'in', 'on', 'at', 'save', 'letter'), $parts);
2462
2463
					$parts['file'] = $filename;
2464
2465
					// These are useless.
2466
					unset($parts['rule'], $parts['type']);
2467
2468
					$rules[$parts['name']][] = $parts;
2469
				}
2470
			}
2471
		}
2472
2473
		return $rules;
2474
	}
2475
2476
	/**
2477
	 * Calculates the distance between the locations of two time zones.
2478
	 *
2479
	 * This somewhat simplistically treats locations on opposite sides of the
2480
	 * antimeridian as maximally distant from each other. But since the antimeridian
2481
	 * is approximately the track of the International Date Line, and locations on
2482
	 * opposite sides of the IDL can't be fallbacks for each other, it's sufficient.
2483
	 * In the unlikely edge case that that we ever need to find a fallback for, say,
2484
	 * a newly created time zone for an island in Kiribati, the worst that could
2485
	 * happen is that we might overlook some better option and therefore end up
2486
	 * suggesting a generic Etc/* time zone as a fallback.
2487
	 *
2488
	 * @param array $this_zone One element from the $this->zones array.
2489
	 * @param array $from_zone Another element from the $this->zones array.
2490
	 * @return float The distance (in degrees) between the two locations.
2491
	 */
2492
	private function get_distance_from($this_zone, $from_zone): float
2493
	{
2494
		foreach (array('latitude', 'longitude') as $varname)
2495
		{
2496
			if (!isset($this_zone[$varname]))
2497
			{
2498
				echo $this_zone['tzid'], " has no $varname.\n";
2499
				return 0;
2500
			}
2501
		}
2502
2503
		$lat_diff = abs($this_zone['latitude'] - $from_zone['latitude']);
2504
		$lng_diff = abs($this_zone['longitude'] - $from_zone['longitude']);
2505
2506
		return sqrt($lat_diff ** 2 + $lng_diff ** 2);
2507
	}
2508
2509
	/**
2510
	 * Rewrites date strings from TZDB format to a PHP-parseable format.
2511
	 *
2512
	 * @param string $date_string A date string in TZDB format.
2513
	 * @return string A date string that can be parsed by strtotime()
2514
	 */
2515
	private function rewrite_date_string(string $date_string): string
2516
	{
2517
		$month = 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?';
2518
		$weekday = 'Sun|Mon|Tue|Wed|Thu|Fri|Sat';
2519
2520
		$replacements = array(
2521
			'/^\h*(\d{4})\h*$/' => '$1-01-01',
2522
2523
			"/(\d{4})\h+($month)\h+last($weekday)/" => 'last $3 of $2 $1,',
2524
2525
			"/(\d{4})\h+($month)\h+($weekday)>=(\d+)/" => '$2 $4 $1 this $3,',
2526
2527
			"/(\d{4})\h+($month)\h*$/" => '$2 $1',
2528
2529
			"/(\d{4})\h+($month)\h+(\d+)/" => '$2 $3 $1,',
2530
		);
2531
2532
		if (strpos($date_string, '<=') !== false)
2533
		{
2534
			$date_string = preg_replace_callback(
2535
				"/(\d{4})\h+($month)\h+($weekday)<=(\d+)/",
2536
				function ($matches)
2537
				{
2538
					$d = new \DateTime($matches[2] . ' ' . $matches[4] . ' ' . $matches[1]);
2539
					$d->add(new \DateInterval('P1D'));
2540
					return $d->format('M j Y') . ' previous ' . $matches[3];
2541
				},
2542
				$date_string
2543
			);
2544
		}
2545
		else
2546
			$date_string = preg_replace(array_keys($replacements), $replacements, $date_string);
2547
2548
		$date_string = rtrim($date_string, ', ');
2549
2550
		// Some rules use '24:00' or even '25:00'
2551
		if (preg_match('/\b(\d+)((?::\d+)+)\b/', $date_string, $matches))
2552
		{
2553
			if ($matches[1] > 23)
2554
			{
2555
				$d = new \DateTime(str_replace($matches[0], ($matches[1] % 24) . $matches[2], $date_string));
2556
				$d->add(new \DateInterval('PT' . ($matches[1] - ($matches[1] % 24)) . 'H'));
2557
				$date_string = $d->format('M j Y, G:i:s');
2558
			}
2559
		}
2560
2561
		return $date_string;
2562
	}
2563
2564
	/**
2565
	 * Generates PHP code to insert into get_tzid_fallbacks() for renamed tzids.
2566
	 *
2567
	 * @param array $renamed_tzids Key-value pairs of renamed tzids.
2568
	 * @return string PHP code to insert into get_tzid_fallbacks()
2569
	 */
2570
	private function generate_rename_fallback_code(array $renamed_tzids): string
2571
	{
2572
		$generated = array();
2573
2574
		foreach ($renamed_tzids as $old_tzid => $new_tzid)
2575
			$generated[$new_tzid] = array(array('ts' => 'PHP_INT_MIN', 'tzid' => $old_tzid));
2576
2577
		return preg_replace(
2578
			array(
2579
				'~\b\d+ =>\s+~',
2580
				'~\barray\s+\(~',
2581
				'~\s+=>\s+array\b~',
2582
				"~'PHP_INT_MIN'~",
2583
				'~  ~',
2584
				'~^~m',
2585
				'~^\s+array\(\n~',
2586
				'~\s+\)$~',
2587
			),
2588
			array(
2589
				'',
2590
				'array(',
2591
				' => array',
2592
				'PHP_INT_MIN',
2593
				"\t",
2594
				"\t",
2595
				'',
2596
				'',
2597
			),
2598
			var_export($generated, true) . "\n"
2599
		);
2600
	}
2601
2602
	/**
2603
	 * Generates PHP code to insert into get_tzid_fallbacks() for new tzids.
2604
	 * Uses the fallback data created by build_fallbacks() to do so.
2605
	 *
2606
	 * @param array $fallbacks Fallback info for tzids.
2607
	 * @return string PHP code to insert into get_tzid_fallbacks()
2608
	 */
2609
	private function generate_full_fallback_code(array $fallbacks): string
2610
	{
2611
		$generated = '';
2612
2613
		foreach ($fallbacks as $tzid => &$entries)
2614
		{
2615
			foreach ($entries as &$entry)
2616
			{
2617
				if (!empty($entry['options']))
2618
				{
2619
					$entry = array(
2620
						'ts' => $entry['ts'],
2621
						'// OPTIONS: ' . implode(', ', $entry['options']),
2622
						'tzid' => $entry['tzid'],
2623
					);
2624
				}
2625
2626
				unset($entry['options']);
2627
			}
2628
2629
			$generated .= preg_replace(
2630
				array(
2631
					'~\b\d+ =>\s+~',
2632
					'~\barray\s+\(~',
2633
					'~\s+=>\s+array\b~',
2634
					"~'PHP_INT_MIN'~",
2635
					"~'ts' => '([^']+)',~",
2636
					"~'(// OPTIONS: [^'\\n]*)',~",
2637
					'~  ~',
2638
					'~^~m',
2639
					'~^\s+array\(\n~',
2640
					'~\s+\)$~',
2641
				),
2642
				array(
2643
					'',
2644
					'array(',
2645
					' => array',
2646
					'PHP_INT_MIN',
2647
					"'ts' => strtotime('$1'),",
2648
					'$1',
2649
					"\t",
2650
					"\t",
2651
					'',
2652
					'',
2653
				),
2654
				var_export(array($tzid => $entries), true) . "\n"
2655
			);
2656
		}
2657
2658
		return $generated;
2659
	}
2660
}
2661
2662
2663
/**
2664
 * A cheap stand-in for the real loadLanguage() function.
2665
 * This one will only load the Timezones language file, which is all we need.
2666
 */
2667
function loadLanguage($template_name, $lang = '', $fatal = true, $force_reload = false)
2668
{
2669
	global $txt, $tztxt;
2670
2671
	if ($template_name !== 'Timezones')
2672
		return;
2673
2674
	include($GLOBALS['langdir'] . '/Timezones.english.php');
2675
}
2676
2677
?>