1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Intl\Data\Generator;
13
14use Symfony\Component\Filesystem\Filesystem;
15use Symfony\Component\Intl\Data\Bundle\Compiler\BundleCompilerInterface;
16use Symfony\Component\Intl\Data\Bundle\Reader\BundleEntryReaderInterface;
17use Symfony\Component\Intl\Data\Util\ArrayAccessibleResourceBundle;
18use Symfony\Component\Intl\Data\Util\LocaleScanner;
19use Symfony\Component\Intl\Exception\MissingResourceException;
20use Symfony\Component\Intl\Locale;
21
22/**
23 * The rule for compiling the zone bundle.
24 *
25 * @author Roland Franssen <franssen.roland@gmail.com>
26 *
27 * @internal
28 */
29class TimezoneDataGenerator extends AbstractDataGenerator
30{
31    use FallbackTrait;
32
33    /**
34     * Collects all available zone IDs.
35     *
36     * @var string[]
37     */
38    private $zoneIds = [];
39    private $zoneToCountryMapping = [];
40    private $localeAliases = [];
41
42    /**
43     * {@inheritdoc}
44     */
45    protected function scanLocales(LocaleScanner $scanner, string $sourceDir): array
46    {
47        $this->localeAliases = $scanner->scanAliases($sourceDir.'/locales');
48
49        return $scanner->scanLocales($sourceDir.'/zone');
50    }
51
52    /**
53     * {@inheritdoc}
54     */
55    protected function compileTemporaryBundles(BundleCompilerInterface $compiler, string $sourceDir, string $tempDir)
56    {
57        $filesystem = new Filesystem();
58        $filesystem->mkdir($tempDir.'/region');
59        $compiler->compile($sourceDir.'/region', $tempDir.'/region');
60        $compiler->compile($sourceDir.'/zone', $tempDir);
61        $compiler->compile($sourceDir.'/misc/timezoneTypes.txt', $tempDir);
62        $compiler->compile($sourceDir.'/misc/metaZones.txt', $tempDir);
63        $compiler->compile($sourceDir.'/misc/windowsZones.txt', $tempDir);
64    }
65
66    /**
67     * {@inheritdoc}
68     */
69    protected function preGenerate()
70    {
71        $this->zoneIds = [];
72        $this->zoneToCountryMapping = [];
73    }
74
75    /**
76     * {@inheritdoc}
77     */
78    protected function generateDataForLocale(BundleEntryReaderInterface $reader, string $tempDir, string $displayLocale): ?array
79    {
80        if (!$this->zoneToCountryMapping) {
81            $this->zoneToCountryMapping = self::generateZoneToCountryMapping($reader->read($tempDir, 'windowsZones'));
82        }
83
84        // Don't generate aliases, as they are resolved during runtime
85        // Unless an alias is needed as fallback for de-duplication purposes
86        if (isset($this->localeAliases[$displayLocale]) && !$this->generatingFallback) {
87            return null;
88        }
89
90        $localeBundle = $reader->read($tempDir, $displayLocale);
91
92        if (!isset($localeBundle['zoneStrings']) || null === $localeBundle['zoneStrings']) {
93            return null;
94        }
95
96        $data = [
97            'Version' => $localeBundle['Version'],
98            'Names' => $this->generateZones($reader, $tempDir, $displayLocale),
99            'Meta' => self::generateZoneMetadata($localeBundle),
100        ];
101
102        // Don't de-duplicate a fallback locale
103        // Ensures the display locale can be de-duplicated on itself
104        if ($this->generatingFallback) {
105            return $data;
106        }
107
108        // Process again to de-duplicate locales and their fallback locales
109        // Only keep the differences
110        $fallback = $this->generateFallbackData($reader, $tempDir, $displayLocale);
111        if (isset($fallback['Names'])) {
112            $data['Names'] = array_diff($data['Names'], $fallback['Names']);
113        }
114        if (isset($fallback['Meta'])) {
115            $data['Meta'] = array_diff($data['Meta'], $fallback['Meta']);
116        }
117        if (!$data['Names'] && !$data['Meta']) {
118            return null;
119        }
120
121        $this->zoneIds = array_merge($this->zoneIds, array_keys($data['Names']));
122
123        return $data;
124    }
125
126    /**
127     * {@inheritdoc}
128     */
129    protected function generateDataForRoot(BundleEntryReaderInterface $reader, string $tempDir): ?array
130    {
131        $rootBundle = $reader->read($tempDir, 'root');
132
133        return [
134            'Version' => $rootBundle['Version'],
135            'Meta' => self::generateZoneMetadata($rootBundle),
136        ];
137    }
138
139    /**
140     * {@inheritdoc}
141     */
142    protected function generateDataForMeta(BundleEntryReaderInterface $reader, string $tempDir): ?array
143    {
144        $rootBundle = $reader->read($tempDir, 'root');
145
146        $this->zoneIds = array_unique($this->zoneIds);
147
148        sort($this->zoneIds);
149        ksort($this->zoneToCountryMapping);
150
151        $data = [
152            'Version' => $rootBundle['Version'],
153            'Zones' => $this->zoneIds,
154            'ZoneToCountry' => $this->zoneToCountryMapping,
155            'CountryToZone' => self::generateCountryToZoneMapping($this->zoneToCountryMapping),
156        ];
157
158        return $data;
159    }
160
161    private function generateZones(BundleEntryReaderInterface $reader, string $tempDir, string $locale): array
162    {
163        $typeBundle = $reader->read($tempDir, 'timezoneTypes');
164        $available = [];
165        foreach ($typeBundle['typeMap']['timezone'] as $zone => $_) {
166            if ('Etc:Unknown' === $zone || preg_match('~^Etc:GMT[-+]\d+$~', $zone)) {
167                continue;
168            }
169
170            $available[$zone] = true;
171        }
172
173        $metaBundle = $reader->read($tempDir, 'metaZones');
174        $metazones = [];
175        foreach ($metaBundle['metazoneInfo'] as $zone => $info) {
176            foreach ($info as $metazone) {
177                $metazones[$zone] = $metazone->get(0);
178            }
179        }
180
181        $regionFormat = $reader->readEntry($tempDir, $locale, ['zoneStrings', 'regionFormat']);
182        $fallbackFormat = $reader->readEntry($tempDir, $locale, ['zoneStrings', 'fallbackFormat']);
183        $resolveName = function (string $id, string $city = null) use ($reader, $tempDir, $locale, $regionFormat, $fallbackFormat): ?string {
184            // Resolve default name as described per http://cldr.unicode.org/translation/timezones
185            if (isset($this->zoneToCountryMapping[$id])) {
186                try {
187                    $country = $reader->readEntry($tempDir.'/region', $locale, ['Countries', $this->zoneToCountryMapping[$id]]);
188                } catch (MissingResourceException $e) {
189                    return null;
190                }
191
192                $name = str_replace('{0}', $country, $regionFormat);
193
194                return null === $city ? $name : str_replace(['{0}', '{1}'], [$city, $name], $fallbackFormat);
195            }
196            if (null !== $city) {
197                return str_replace('{0}', $city, $regionFormat);
198            }
199
200            return null;
201        };
202        $accessor = static function (array $indices, array ...$fallbackIndices) use ($locale, $reader, $tempDir) {
203            foreach (\func_get_args() as $indices) {
204                try {
205                    return $reader->readEntry($tempDir, $locale, $indices);
206                } catch (MissingResourceException $e) {
207                }
208            }
209
210            return null;
211        };
212        $zones = [];
213        foreach (array_keys($available) as $zone) {
214            // lg: long generic, e.g. "Central European Time"
215            // ls: long specific (not DST), e.g. "Central European Standard Time"
216            // ld: long DST, e.g. "Central European Summer Time"
217            // ec: example city, e.g. "Amsterdam"
218            $name = $accessor(['zoneStrings', $zone, 'lg'], ['zoneStrings', $zone, 'ls']);
219            $city = $accessor(['zoneStrings', $zone, 'ec']);
220            $id = str_replace(':', '/', $zone);
221
222            if (null === $name && isset($metazones[$zone])) {
223                $meta = 'meta:'.$metazones[$zone];
224                $name = $accessor(['zoneStrings', $meta, 'lg'], ['zoneStrings', $meta, 'ls']);
225            }
226
227            // Infer a default English named city for all locales
228            // Ensures each timezone ID has a distinctive name
229            if (null === $city && 0 !== strrpos($zone, 'Etc:') && false !== $i = strrpos($zone, ':')) {
230                $city = str_replace('_', ' ', substr($zone, $i + 1));
231            }
232            if (null === $name) {
233                $name = $resolveName($id, $city);
234                $city = null;
235            }
236            if (null === $name) {
237                continue;
238            }
239
240            // Ensure no duplicated content is generated
241            if (null !== $city && false === mb_stripos(str_replace('-', ' ', $name), str_replace('-', ' ', $city))) {
242                $name = str_replace(['{0}', '{1}'], [$city, $name], $fallbackFormat);
243            }
244
245            $zones[$id] = $name;
246        }
247
248        return $zones;
249    }
250
251    private static function generateZoneMetadata(ArrayAccessibleResourceBundle $localeBundle): array
252    {
253        $metadata = [];
254        if (isset($localeBundle['zoneStrings']['gmtFormat'])) {
255            $metadata['GmtFormat'] = str_replace('{0}', '%s', $localeBundle['zoneStrings']['gmtFormat']);
256        }
257        if (isset($localeBundle['zoneStrings']['hourFormat'])) {
258            $hourFormat = explode(';', str_replace(['HH', 'mm', 'H', 'm'], ['%02d', '%02d', '%d', '%d'], $localeBundle['zoneStrings']['hourFormat']), 2);
259            $metadata['HourFormatPos'] = $hourFormat[0];
260            $metadata['HourFormatNeg'] = $hourFormat[1];
261        }
262
263        return $metadata;
264    }
265
266    private static function generateZoneToCountryMapping(ArrayAccessibleResourceBundle $windowsZoneBundle): array
267    {
268        $mapping = [];
269
270        foreach ($windowsZoneBundle['mapTimezones'] as $zoneInfo) {
271            foreach ($zoneInfo as $region => $zones) {
272                if (RegionDataGenerator::isValidCountryCode($region)) {
273                    $mapping += array_fill_keys(explode(' ', $zones), $region);
274                }
275            }
276        }
277
278        ksort($mapping);
279
280        return $mapping;
281    }
282
283    private static function generateCountryToZoneMapping(array $zoneToCountryMapping): array
284    {
285        $mapping = [];
286
287        foreach ($zoneToCountryMapping as $zone => $country) {
288            $mapping[$country][] = $zone;
289        }
290
291        ksort($mapping);
292
293        return $mapping;
294    }
295}
296