src/impl/locale.js
import { Util } from './util';
import { English } from './english';
import { Settings } from '../settings';
import { DateTime } from '../datetime';
import { Formatter } from './formatter';
let localeCache = {},
sysLocaleCache = null;
function systemLocale() {
if (sysLocaleCache) {
return sysLocaleCache;
} else if (Util.hasIntl()) {
sysLocaleCache = new Intl.DateTimeFormat().resolvedOptions().locale;
return sysLocaleCache;
} else {
sysLocaleCache = 'en-US';
return sysLocaleCache;
}
}
function intlConfigString(locale, numberingSystem, outputCalendar) {
if (Util.hasIntl()) {
locale = Array.isArray(locale) ? locale : [locale];
if (outputCalendar || numberingSystem) {
locale = locale.map(l => {
l += '-u';
if (outputCalendar) {
l += '-ca-' + outputCalendar;
}
if (numberingSystem) {
l += '-nu-' + numberingSystem;
}
return l;
});
}
return locale;
} else {
return [];
}
}
function mapMonths(f) {
const ms = [];
for (let i = 1; i <= 12; i++) {
const dt = DateTime.utc(2016, i, 1);
ms.push(f(dt));
}
return ms;
}
function mapWeekdays(f) {
const ms = [];
for (let i = 1; i <= 7; i++) {
const dt = DateTime.utc(2016, 11, 13 + i);
ms.push(f(dt));
}
return ms;
}
function listStuff(loc, length, defaultOK, englishFn, intlFn) {
const mode = loc.listingMode(defaultOK);
if (mode === 'error') {
return null;
} else if (mode === 'en') {
return englishFn(length);
} else {
return intlFn(length);
}
}
/**
* @private
*/
class PolyNumberFormatter {
constructor(opts) {
this.padTo = opts.padTo || 0;
this.round = opts.round || false;
}
format(i) {
const maybeRounded = this.round ? Math.round(i) : i;
return maybeRounded.toString().padStart(this.padTo, '0');
}
}
class PolyDateFormatter {
constructor(dt, intl, opts) {
this.opts = opts;
this.hasIntl = Util.hasIntl();
let z;
if (dt.zone.universal) {
// if we have a fixed-offset zone that isn't actually UTC,
// (like UTC+8), we need to make do with just displaying
// the time in UTC; the formatter doesn't know how to handle UTC+8
this.dt = Util.asIfUTC(dt);
z = 'UTC';
} else if (dt.zone.type === 'local') {
this.dt = dt;
} else {
this.dt = dt;
z = dt.zone.name;
}
if (this.hasIntl) {
const realIntlOpts = Object.assign({}, this.opts);
if (z) {
realIntlOpts.timeZone = z;
}
this.dtf = new Intl.DateTimeFormat(intl, realIntlOpts);
}
}
format() {
if (this.hasIntl) {
return this.dtf.format(this.dt.toJSDate());
} else {
const tokenFormat = English.formatString(this.opts),
loc = Locale.create('en-US');
return Formatter.create(loc).formatDateTimeFromString(this.dt, tokenFormat);
}
}
formatToParts() {
if (this.hasIntl && Util.hasFormatToParts()) {
return this.dtf.formatToParts(this.dt.toJSDate());
} else {
// This is kind of a cop out. We actually could do this for English. However, we couldn't do it for intl strings
// and IMO it's too weird to have an uncanny valley like that
return [];
}
}
resolvedOptions() {
if (this.hasIntl) {
return this.dtf.resolvedOptions();
} else {
return {
locale: 'en-US',
numberingSystem: 'latn',
outputCalendar: 'gregory'
};
}
}
}
/**
* @private
*/
export class Locale {
static fromOpts(opts) {
return Locale.create(opts.locale, opts.numberingSystem, opts.outputCalendar, opts.defaultToEN);
}
static create(locale, numberingSystem, outputCalendar, defaultToEN = false) {
const specifiedLocale = locale || Settings.defaultLocale,
// the system locale is useful for human readable strings but annoying for parsing known formats
localeR = specifiedLocale || (defaultToEN ? 'en-US' : systemLocale()),
numberingSystemR = numberingSystem || Settings.defaultNumberingSystem,
outputCalendarR = outputCalendar || Settings.defaultOutputCalendar,
cacheKey = `${localeR}|${numberingSystemR}|${outputCalendarR}|${specifiedLocale}`,
cached = localeCache[cacheKey];
if (cached) {
return cached;
} else {
const fresh = new Locale(localeR, numberingSystemR, outputCalendarR, specifiedLocale);
localeCache[cacheKey] = fresh;
return fresh;
}
}
static resetCache() {
sysLocaleCache = null;
localeCache = {};
}
static fromObject({ locale, numberingSystem, outputCalendar } = {}) {
return Locale.create(locale, numberingSystem, outputCalendar);
}
constructor(locale, numbering, outputCalendar, specifiedLocale) {
Object.defineProperty(this, 'locale', { value: locale, enumerable: true });
Object.defineProperty(this, 'numberingSystem', {
value: numbering,
enumerable: true
});
Object.defineProperty(this, 'outputCalendar', {
value: outputCalendar,
enumerable: true
});
Object.defineProperty(this, 'intl', {
value: intlConfigString(this.locale, this.numberingSystem, this.outputCalendar),
enumerable: false
});
// cached usefulness
Object.defineProperty(this, 'weekdaysCache', {
value: { format: {}, standalone: {} },
enumerable: false
});
Object.defineProperty(this, 'monthsCache', {
value: { format: {}, standalone: {} },
enumerable: false
});
Object.defineProperty(this, 'meridiemCache', {
value: null,
enumerable: false,
writable: true
});
Object.defineProperty(this, 'eraCache', {
value: {},
enumerable: false,
writable: true
});
Object.defineProperty(this, 'specifiedLocale', { value: specifiedLocale, enumerable: true });
}
// todo: cache me
listingMode(defaultOk = true) {
const hasIntl = Util.hasIntl(),
hasFTP = hasIntl && Util.hasFormatToParts(),
isActuallyEn =
this.locale === 'en' ||
this.locale.toLowerCase() === 'en-us' ||
(hasIntl &&
Intl.DateTimeFormat(this.intl)
.resolvedOptions()
.locale.startsWith('en-us')),
hasNoWeirdness =
(this.numberingSystem === null || this.numberingSystem === 'latn') &&
(this.outputCalendar === null || this.outputCalendar === 'gregory');
if (!hasFTP && !(isActuallyEn && hasNoWeirdness) && !defaultOk) {
return 'error';
} else if (!hasFTP || (isActuallyEn && hasNoWeirdness)) {
return 'en';
} else {
return 'intl';
}
}
clone(alts) {
if (!alts || Object.getOwnPropertyNames(alts).length === 0) {
return this;
} else {
return Locale.create(
alts.locale || this.specifiedLocale,
alts.numberingSystem || this.numberingSystem,
alts.outputCalendar || this.outputCalendar,
alts.defaultToEN || false
);
}
}
redefaultToEN(alts = {}) {
return this.clone(Object.assign({}, alts, { defaultToEN: true }));
}
months(length, format = false, defaultOK = true) {
return listStuff(this, length, defaultOK, English.months, () => {
const intl = format ? { month: length, day: 'numeric' } : { month: length },
formatStr = format ? 'format' : 'standalone';
if (!this.monthsCache[formatStr][length]) {
this.monthsCache[formatStr][length] = mapMonths(dt => this.extract(dt, intl, 'month'));
}
return this.monthsCache[formatStr][length];
});
}
weekdays(length, format = false, defaultOK = true) {
return listStuff(this, length, defaultOK, English.weekdays, () => {
const intl = format
? { weekday: length, year: 'numeric', month: 'long', day: 'numeric' }
: { weekday: length },
formatStr = format ? 'format' : 'standalone';
if (!this.weekdaysCache[formatStr][length]) {
this.weekdaysCache[formatStr][length] = mapWeekdays(dt =>
this.extract(dt, intl, 'weekday')
);
}
return this.weekdaysCache[formatStr][length];
});
}
meridiems(defaultOK = true) {
return listStuff(
this,
undefined,
defaultOK,
() => English.meridiems,
() => {
// In theory there could be aribitrary day periods. We're gonna assume there are exactly two
// for AM and PM. This is probably wrong, but it's makes parsing way easier.
if (!this.meridiemCache) {
const intl = { hour: 'numeric', hour12: true };
this.meridiemCache = [
DateTime.utc(2016, 11, 13, 9),
DateTime.utc(2016, 11, 13, 19)
].map(dt => this.extract(dt, intl, 'dayperiod'));
}
return this.meridiemCache;
}
);
}
eras(length, defaultOK = true) {
return listStuff(this, length, defaultOK, English.eras, () => {
const intl = { era: length };
// This is utter bullshit. Different calendars are going to define eras totally differently. What I need is the minimum set of dates
// to definitely enumerate them.
if (!this.eraCache[length]) {
this.eraCache[length] = [DateTime.utc(-40, 1, 1), DateTime.utc(2017, 1, 1)].map(dt =>
this.extract(dt, intl, 'era')
);
}
return this.eraCache[length];
});
}
extract(dt, intlOpts, field) {
const df = this.dtFormatter(dt, intlOpts),
results = df.formatToParts(),
matching = results.find(m => m.type.toLowerCase() === field);
return matching ? matching.value : null;
}
numberFormatter(opts = {}, intlOpts = {}) {
if (Util.hasIntl()) {
const realIntlOpts = Object.assign({ useGrouping: false }, intlOpts);
if (opts.padTo > 0) {
realIntlOpts.minimumIntegerDigits = opts.padTo;
}
if (opts.round) {
realIntlOpts.maximumFractionDigits = 0;
}
return new Intl.NumberFormat(this.intl, realIntlOpts);
} else {
return new PolyNumberFormatter(opts);
}
}
dtFormatter(dt, intlOpts = {}) {
return new PolyDateFormatter(dt, this.intl, intlOpts);
}
equals(other) {
return (
this.locale === other.locale &&
this.numberingSystem === other.numberingSystem &&
this.outputCalendar === other.outputCalendar
);
}
}