packages/locale/src/format/cldr/CldrZoneTextPrinterParser.js
/*
* @copyright (c) 2017, Philipp Thuerwaechter & Pattrick Hueper
* @license BSD-3-Clause (see LICENSE.md in the root directory of this source tree)
*/
import {
_ as jodaInternal,
ChronoField,
DateTimeException,
DateTimeFormatterBuilder,
Instant,
TextStyle,
TemporalQueries,
ZoneId,
ZoneOffset,
ZoneRulesProvider,
} from '@js-joda/core';
import { getOrCreateCldrInstance, getOrCreateMapZones, loadCldrData } from './CldrCache';
const { assert: { requireNonNull, requireInstance } } = jodaInternal;
//-----------------------------------------------------------------------
const LENGTH_COMPARATOR = (str1, str2) => {
let cmp = str2.length - str1.length;
if (cmp === 0) {
cmp = str1.localeCompare(str2);
}
return cmp;
};
/**
* Cache for `_cachedResolveZoneIdText`.
*
* Its basic structure is:
* Obj { locale: zoneId }
* Obj { zoneId: style}
* Obj { style: type}
* Obj { type: metazone}
* Obj { metazone: resolvedZoneIdText}
*
* A resolved text may be undefined, if there is no text for the zone.
*/
const resolveZoneIdTextCache = {};
/**
* Parses the offset after a UT/UTC/GMT prefix, or a plain offset.
* Uses '0' as no offset text, so that a trailing 'Z' is not consumed.
*/
const OFFSET_ID_PARSER = new DateTimeFormatterBuilder.OffsetIdPrinterParser('0', '+HH:MM:ss');
/**
* Parses a metazone date of the cldr supplemental data, e.g. '1991-10-27 07:00', as UTC.
*
* @param {String} text
* @return {number} the epoch milli
*/
const parseMetazoneDate = (text) => {
const [date, time] = text.split(' ');
const [year, month, day] = date.split('-').map(Number);
const [hour, minute] = time.split(':').map(Number);
return Date.UTC(year, month - 1, day, hour, minute);
};
/**
* Checks if the error tells that the zone rules don't support `isDaylightSavings()`:
* - `ZoneRules` that don't implement it (TypeError: abstract method ... is not implemented)
* - `@js-joda/timezone` before 3.1 (Error: not supported: ...)
* - `@js-joda/timezone` 3.1 or later with tz data without standard offsets
*
* @param {*} ex
* @return {boolean}
*/
const isDaylightSavingsUnsupported = (ex) => {
const message = ex != null && typeof ex.message === 'string' ? ex.message : '';
return message.indexOf('abstract method') >= 0 ||
message.indexOf('not supported') >= 0 ||
message.indexOf('has no standard offsets') >= 0;
};
/**
* Prints or parses a zone ID.
*/
export default class CldrZoneTextPrinterParser {
/** The text style to output. */
constructor(textStyle) {
requireNonNull(textStyle, 'textStyle');
requireInstance(textStyle, TextStyle, 'textStyle');
this._textStyle = textStyle;
this._zoneIdsLocales = {};
loadCldrData('supplemental/likelySubtags.json');
loadCldrData('supplemental/metaZones.json');
}
/**
* @param {Cldr} cldr
* @param {String} zoneId
* @param {String} style - 'long' or 'short'
* @param {String} type - 'generic', 'standard' or 'daylight'
* @param {String} [metazone] - the metazone of the zone, defaults to its current metazone
* @return {String|undefined} the zone text
*/
_cachedResolveZoneIdText(cldr, zoneId, style, type, metazone) {
if (resolveZoneIdTextCache[cldr.locale] == null) {
resolveZoneIdTextCache[cldr.locale] = {};
}
const zoneIdToStyle = resolveZoneIdTextCache[cldr.locale];
if (zoneIdToStyle[zoneId] == null) {
zoneIdToStyle[zoneId] = {};
}
const styleToType = zoneIdToStyle[zoneId];
if (styleToType[style] == null) {
styleToType[style] = {};
}
const typeToMetazone = styleToType[style];
if (typeToMetazone[type] == null) {
typeToMetazone[type] = {};
}
const metazoneToResolvedZoneIdText = typeToMetazone[type];
const metazoneKey = metazone == null ? '' : metazone;
// a resolved text may be undefined, so check the key, to cache misses as well
if (!Object.prototype.hasOwnProperty.call(metazoneToResolvedZoneIdText, metazoneKey)) {
metazoneToResolvedZoneIdText[metazoneKey] = this._resolveZoneIdText(cldr, zoneId, style, type, metazone);
}
return metazoneToResolvedZoneIdText[metazoneKey];
}
/**
* Gets the metazone that the zone uses at the instant.
*
* @param {Cldr} cldr
* @param {String} zoneId
* @param {number} [epochMilli] - the instant, defaults to the current metazone
* @return {String|undefined} the metazone, undefined if the zone has none at the instant
*/
_metazone(cldr, zoneId, epochMilli) {
const metazoneInfo = cldr.get(`supplemental/metaZones/metazoneInfo/timezone/${zoneId}`);
if (!metazoneInfo) {
return undefined;
}
if (epochMilli == null) {
return metazoneInfo[metazoneInfo.length - 1]['usesMetazone']['_mzone'];
}
for (const info of metazoneInfo) {
const { _mzone, _from, _to } = info['usesMetazone'];
if ((_from == null || parseMetazoneDate(_from) <= epochMilli) &&
(_to == null || epochMilli < parseMetazoneDate(_to))) {
return _mzone;
}
}
return undefined;
}
_resolveZoneIdText(cldr, zoneId, style, type, metazone) {
const zoneData = cldr.main(`dates/timeZoneNames/zone/${zoneId}/${style}/${type}`);
if (zoneData) {
return zoneData;
} else {
if (metazone == null) {
metazone = this._metazone(cldr, zoneId);
}
if (metazone) {
let metaZoneData = cldr.main(`dates/timeZoneNames/metazone/${metazone}/${style}/${type}`);
if (metaZoneData) {
return metaZoneData;
} else {
// type fallback, first generic, then standard; a daylight name never falls
// back to the standard name, which would denote a different offset
metaZoneData = cldr.main(`dates/timeZoneNames/metazone/${metazone}/${style}/generic`);
if (!metaZoneData && type !== 'daylight') {
metaZoneData = cldr.main(`dates/timeZoneNames/metazone/${metazone}/${style}/standard`);
}
if (metaZoneData) {
return metaZoneData;
} else {
const mapZones = getOrCreateMapZones(cldr);
// find preferred Zone and resolve again
const preferredZone = mapZones[metazone][cldr.attributes.territory];
if (preferredZone) {
if (preferredZone !== zoneId) {
return this._cachedResolveZoneIdText(cldr, preferredZone, style, type, metazone);
}
} else {
// find golden Zone and resolve again
const goldenZone = mapZones[metazone]['001'];
if (goldenZone !== zoneId) {
return this._cachedResolveZoneIdText(cldr, goldenZone, style, type, metazone);
}
}
}
}
}
}
}
//-----------------------------------------------------------------------
print(context, buf) {
//see http://www.unicode.org/reports/tr35/tr35-dates.html#Time_Zone_Names
const zone = context.getValueQuery(TemporalQueries.zoneId());
/* istanbul ignore if */ // shouldn't happen... getValueQuery throws before returning null
if (zone == null) {
return false;
}
if (zone.normalized() instanceof ZoneOffset) {
buf.append(zone.id());
return true;
}
let instant = null;
let daylight = false;
let hasDaylightSupport = false;
const temporal = context.temporal();
if (temporal.isSupported(ChronoField.INSTANT_SECONDS)) {
instant = Instant.ofEpochSecond(temporal.getLong(ChronoField.INSTANT_SECONDS));
try {
daylight = zone.rules().isDaylightSavings(instant);
hasDaylightSupport = true;
} catch (ex) {
if (!isDaylightSavingsUnsupported(ex)) {
throw ex;
}
// the rules have no daylight savings information, fall back to the generic name
}
}
const tzType = hasDaylightSupport ? (daylight ? 'daylight' : 'standard') : 'generic';
const tzstyle = (this._textStyle.asNormal() === TextStyle.FULL ? 'long' : 'short');
loadCldrData(`main/${context.locale().localeString()}/timeZoneNames.json`);
const cldr = getOrCreateCldrInstance(context.locale().localeString());
// the metazone at the instant, so that a historic date gets the name of the offset in use then
const metazone = instant == null ? undefined : this._metazone(cldr, zone.id(), instant.toEpochMilli());
const text = this._cachedResolveZoneIdText(cldr, zone.id(), tzstyle, tzType, metazone);
if (text) {
buf.append(text);
} else {
// fallback, print zoneId
buf.append(zone.id());
}
return true;
}
_resolveZoneIds(localString) {
if(this._zoneIdsLocales[localString] != null) {
return this._zoneIdsLocales[localString];
}
const ids = {};
loadCldrData(`main/${localString}/timeZoneNames.json`);
const cldr = getOrCreateCldrInstance(localString);
for (const id of ZoneRulesProvider.getAvailableZoneIds()) {
ids[id] = id;
const tzstyle = (this._textStyle.asNormal() === TextStyle.FULL ? 'long' : 'short');
const genericText = this._cachedResolveZoneIdText(cldr, id, tzstyle, 'generic');
if (genericText) {
ids[genericText] = id;
}
const standardText = this._cachedResolveZoneIdText(cldr, id, tzstyle, 'standard');
if (standardText) {
ids[standardText] = id;
}
const daylightText = this._cachedResolveZoneIdText(cldr, id, tzstyle, 'daylight');
if (daylightText) {
ids[daylightText] = id;
}
}
// threeten is using a (sorted) TreeMap... so we need to sort the keys
const sortedKeys = Object.keys(ids).sort(LENGTH_COMPARATOR);
this._zoneIdsLocales[localString] = { ids, sortedKeys };
return this._zoneIdsLocales[localString];
}
parse(context, text, position) {
// handle fixed offsets
const length = text.length;
if (position >= length) {
return ~position;
}
const first = text.charAt(position);
if (first === '+' || first === '-') {
if (position + 6 > length) {
return ~position;
}
return this._parseOffset(context, text, position, '');
}
for (const prefix of ['GMT', 'UTC', 'UT']) {
if (context.subSequenceEquals(text, position, prefix, 0, prefix.length)) {
return this._parseOffset(context, text, position, prefix);
}
}
// this is a poor implementation that handles some but not all of the spec
const { ids, sortedKeys } = this._resolveZoneIds(context.locale().localeString());
for (const name of sortedKeys) {
if (context.subSequenceEquals(text, position, name, 0, name.length)) {
context.setParsedZone(ZoneId.of(ids[name]));
return position + name.length;
}
}
if (context.charEquals(first, 'Z')) {
context.setParsedZone(ZoneOffset.UTC);
return position + 1;
}
return ~position;
}
/**
* Parses an optional offset after a UT/UTC/GMT prefix, or a plain offset if the prefix is empty.
*
* @param {DateTimeParseContext} context
* @param {String} text
* @param {number} position
* @param {String} prefix
* @return {number}
*/
_parseOffset(context, text, position, prefix) {
const searchPos = position + prefix.length;
if (searchPos >= text.length) {
context.setParsedZone(ZoneId.of(prefix));
return searchPos;
}
const first = text.charAt(searchPos);
if (first !== '+' && first !== '-') {
context.setParsedZone(ZoneId.of(prefix));
return searchPos;
}
const contextCopy = context.copy();
try {
const endPos = OFFSET_ID_PARSER.parse(contextCopy, text, searchPos);
if (endPos < 0) {
if (prefix.length === 0) {
return ~position;
}
context.setParsedZone(ZoneId.of(prefix));
return searchPos;
}
const offset = ZoneOffset.ofTotalSeconds(contextCopy.getParsed(ChronoField.OFFSET_SECONDS));
context.setParsedZone(prefix.length === 0 ? offset : ZoneId.ofOffset(prefix, offset));
return endPos;
} catch (ex) {
if (ex instanceof DateTimeException) {
return ~position;
}
throw ex;
}
}
toString() {
return `ZoneText(${this._textStyle})`;
}
}