You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
495 lines
14 KiB
495 lines
14 KiB
import { padStart, roundTo, hasRelative, formatOffset } from "./util.js";
|
|
import * as English from "./english.js";
|
|
import Settings from "../settings.js";
|
|
import DateTime from "../datetime.js";
|
|
import IANAZone from "../zones/IANAZone.js";
|
|
|
|
// todo - remap caching
|
|
|
|
let intlLFCache = {};
|
|
function getCachedLF(locString, opts = {}) {
|
|
const key = JSON.stringify([locString, opts]);
|
|
let dtf = intlLFCache[key];
|
|
if (!dtf) {
|
|
dtf = new Intl.ListFormat(locString, opts);
|
|
intlLFCache[key] = dtf;
|
|
}
|
|
return dtf;
|
|
}
|
|
|
|
let intlDTCache = {};
|
|
function getCachedDTF(locString, opts = {}) {
|
|
const key = JSON.stringify([locString, opts]);
|
|
let dtf = intlDTCache[key];
|
|
if (!dtf) {
|
|
dtf = new Intl.DateTimeFormat(locString, opts);
|
|
intlDTCache[key] = dtf;
|
|
}
|
|
return dtf;
|
|
}
|
|
|
|
let intlNumCache = {};
|
|
function getCachedINF(locString, opts = {}) {
|
|
const key = JSON.stringify([locString, opts]);
|
|
let inf = intlNumCache[key];
|
|
if (!inf) {
|
|
inf = new Intl.NumberFormat(locString, opts);
|
|
intlNumCache[key] = inf;
|
|
}
|
|
return inf;
|
|
}
|
|
|
|
let intlRelCache = {};
|
|
function getCachedRTF(locString, opts = {}) {
|
|
const { base, ...cacheKeyOpts } = opts; // exclude `base` from the options
|
|
const key = JSON.stringify([locString, cacheKeyOpts]);
|
|
let inf = intlRelCache[key];
|
|
if (!inf) {
|
|
inf = new Intl.RelativeTimeFormat(locString, opts);
|
|
intlRelCache[key] = inf;
|
|
}
|
|
return inf;
|
|
}
|
|
|
|
let sysLocaleCache = null;
|
|
function systemLocale() {
|
|
if (sysLocaleCache) {
|
|
return sysLocaleCache;
|
|
} else {
|
|
sysLocaleCache = new Intl.DateTimeFormat().resolvedOptions().locale;
|
|
return sysLocaleCache;
|
|
}
|
|
}
|
|
|
|
function parseLocaleString(localeStr) {
|
|
// I really want to avoid writing a BCP 47 parser
|
|
// see, e.g. https://github.com/wooorm/bcp-47
|
|
// Instead, we'll do this:
|
|
|
|
// a) if the string has no -u extensions, just leave it alone
|
|
// b) if it does, use Intl to resolve everything
|
|
// c) if Intl fails, try again without the -u
|
|
|
|
// private subtags and unicode subtags have ordering requirements,
|
|
// and we're not properly parsing this, so just strip out the
|
|
// private ones if they exist.
|
|
const xIndex = localeStr.indexOf("-x-");
|
|
if (xIndex !== -1) {
|
|
localeStr = localeStr.substring(0, xIndex);
|
|
}
|
|
|
|
const uIndex = localeStr.indexOf("-u-");
|
|
if (uIndex === -1) {
|
|
return [localeStr];
|
|
} else {
|
|
let options;
|
|
let selectedStr;
|
|
try {
|
|
options = getCachedDTF(localeStr).resolvedOptions();
|
|
selectedStr = localeStr;
|
|
} catch (e) {
|
|
const smaller = localeStr.substring(0, uIndex);
|
|
options = getCachedDTF(smaller).resolvedOptions();
|
|
selectedStr = smaller;
|
|
}
|
|
|
|
const { numberingSystem, calendar } = options;
|
|
return [selectedStr, numberingSystem, calendar];
|
|
}
|
|
}
|
|
|
|
function intlConfigString(localeStr, numberingSystem, outputCalendar) {
|
|
if (outputCalendar || numberingSystem) {
|
|
if (!localeStr.includes("-u-")) {
|
|
localeStr += "-u";
|
|
}
|
|
|
|
if (outputCalendar) {
|
|
localeStr += `-ca-${outputCalendar}`;
|
|
}
|
|
|
|
if (numberingSystem) {
|
|
localeStr += `-nu-${numberingSystem}`;
|
|
}
|
|
return localeStr;
|
|
} else {
|
|
return localeStr;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function supportsFastNumbers(loc) {
|
|
if (loc.numberingSystem && loc.numberingSystem !== "latn") {
|
|
return false;
|
|
} else {
|
|
return (
|
|
loc.numberingSystem === "latn" ||
|
|
!loc.locale ||
|
|
loc.locale.startsWith("en") ||
|
|
new Intl.DateTimeFormat(loc.intl).resolvedOptions().numberingSystem === "latn"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
|
|
class PolyNumberFormatter {
|
|
constructor(intl, forceSimple, opts) {
|
|
this.padTo = opts.padTo || 0;
|
|
this.floor = opts.floor || false;
|
|
|
|
const { padTo, floor, ...otherOpts } = opts;
|
|
|
|
if (!forceSimple || Object.keys(otherOpts).length > 0) {
|
|
const intlOpts = { useGrouping: false, ...opts };
|
|
if (opts.padTo > 0) intlOpts.minimumIntegerDigits = opts.padTo;
|
|
this.inf = getCachedINF(intl, intlOpts);
|
|
}
|
|
}
|
|
|
|
format(i) {
|
|
if (this.inf) {
|
|
const fixed = this.floor ? Math.floor(i) : i;
|
|
return this.inf.format(fixed);
|
|
} else {
|
|
// to match the browser's numberformatter defaults
|
|
const fixed = this.floor ? Math.floor(i) : roundTo(i, 3);
|
|
return padStart(fixed, this.padTo);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
|
|
class PolyDateFormatter {
|
|
constructor(dt, intl, opts) {
|
|
this.opts = opts;
|
|
this.originalZone = undefined;
|
|
|
|
let z = undefined;
|
|
if (this.opts.timeZone) {
|
|
// Don't apply any workarounds if a timeZone is explicitly provided in opts
|
|
this.dt = dt;
|
|
} else if (dt.zone.type === "fixed") {
|
|
// UTC-8 or Etc/UTC-8 are not part of tzdata, only Etc/GMT+8 and the like.
|
|
// That is why fixed-offset TZ is set to that unless it is:
|
|
// 1. Representing offset 0 when UTC is used to maintain previous behavior and does not become GMT.
|
|
// 2. Unsupported by the browser:
|
|
// - some do not support Etc/
|
|
// - < Etc/GMT-14, > Etc/GMT+12, and 30-minute or 45-minute offsets are not part of tzdata
|
|
const gmtOffset = -1 * (dt.offset / 60);
|
|
const offsetZ = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`;
|
|
if (dt.offset !== 0 && IANAZone.create(offsetZ).valid) {
|
|
z = offsetZ;
|
|
this.dt = dt;
|
|
} else {
|
|
// Not all fixed-offset zones like Etc/+4:30 are present in tzdata so
|
|
// we manually apply the offset and substitute the zone as needed.
|
|
z = "UTC";
|
|
this.dt = dt.offset === 0 ? dt : dt.setZone("UTC").plus({ minutes: dt.offset });
|
|
this.originalZone = dt.zone;
|
|
}
|
|
} else if (dt.zone.type === "system") {
|
|
this.dt = dt;
|
|
} else if (dt.zone.type === "iana") {
|
|
this.dt = dt;
|
|
z = dt.zone.name;
|
|
} else {
|
|
// Custom zones can have any offset / offsetName so we just manually
|
|
// apply the offset and substitute the zone as needed.
|
|
z = "UTC";
|
|
this.dt = dt.setZone("UTC").plus({ minutes: dt.offset });
|
|
this.originalZone = dt.zone;
|
|
}
|
|
|
|
const intlOpts = { ...this.opts };
|
|
intlOpts.timeZone = intlOpts.timeZone || z;
|
|
this.dtf = getCachedDTF(intl, intlOpts);
|
|
}
|
|
|
|
format() {
|
|
if (this.originalZone) {
|
|
// If we have to substitute in the actual zone name, we have to use
|
|
// formatToParts so that the timezone can be replaced.
|
|
return this.formatToParts()
|
|
.map(({ value }) => value)
|
|
.join("");
|
|
}
|
|
return this.dtf.format(this.dt.toJSDate());
|
|
}
|
|
|
|
formatToParts() {
|
|
const parts = this.dtf.formatToParts(this.dt.toJSDate());
|
|
if (this.originalZone) {
|
|
return parts.map((part) => {
|
|
if (part.type === "timeZoneName") {
|
|
const offsetName = this.originalZone.offsetName(this.dt.ts, {
|
|
locale: this.dt.locale,
|
|
format: this.opts.timeZoneName,
|
|
});
|
|
return {
|
|
...part,
|
|
value: offsetName,
|
|
};
|
|
} else {
|
|
return part;
|
|
}
|
|
});
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
resolvedOptions() {
|
|
return this.dtf.resolvedOptions();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
class PolyRelFormatter {
|
|
constructor(intl, isEnglish, opts) {
|
|
this.opts = { style: "long", ...opts };
|
|
if (!isEnglish && hasRelative()) {
|
|
this.rtf = getCachedRTF(intl, opts);
|
|
}
|
|
}
|
|
|
|
format(count, unit) {
|
|
if (this.rtf) {
|
|
return this.rtf.format(count, unit);
|
|
} else {
|
|
return English.formatRelativeTime(unit, count, this.opts.numeric, this.opts.style !== "long");
|
|
}
|
|
}
|
|
|
|
formatToParts(count, unit) {
|
|
if (this.rtf) {
|
|
return this.rtf.formatToParts(count, unit);
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
|
|
export default 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/formatting known formats
|
|
const localeR = specifiedLocale || (defaultToEN ? "en-US" : systemLocale());
|
|
const numberingSystemR = numberingSystem || Settings.defaultNumberingSystem;
|
|
const outputCalendarR = outputCalendar || Settings.defaultOutputCalendar;
|
|
return new Locale(localeR, numberingSystemR, outputCalendarR, specifiedLocale);
|
|
}
|
|
|
|
static resetCache() {
|
|
sysLocaleCache = null;
|
|
intlDTCache = {};
|
|
intlNumCache = {};
|
|
intlRelCache = {};
|
|
}
|
|
|
|
static fromObject({ locale, numberingSystem, outputCalendar } = {}) {
|
|
return Locale.create(locale, numberingSystem, outputCalendar);
|
|
}
|
|
|
|
constructor(locale, numbering, outputCalendar, specifiedLocale) {
|
|
const [parsedLocale, parsedNumberingSystem, parsedOutputCalendar] = parseLocaleString(locale);
|
|
|
|
this.locale = parsedLocale;
|
|
this.numberingSystem = numbering || parsedNumberingSystem || null;
|
|
this.outputCalendar = outputCalendar || parsedOutputCalendar || null;
|
|
this.intl = intlConfigString(this.locale, this.numberingSystem, this.outputCalendar);
|
|
|
|
this.weekdaysCache = { format: {}, standalone: {} };
|
|
this.monthsCache = { format: {}, standalone: {} };
|
|
this.meridiemCache = null;
|
|
this.eraCache = {};
|
|
|
|
this.specifiedLocale = specifiedLocale;
|
|
this.fastNumbersCached = null;
|
|
}
|
|
|
|
get fastNumbers() {
|
|
if (this.fastNumbersCached == null) {
|
|
this.fastNumbersCached = supportsFastNumbers(this);
|
|
}
|
|
|
|
return this.fastNumbersCached;
|
|
}
|
|
|
|
listingMode() {
|
|
const isActuallyEn = this.isEnglish();
|
|
const hasNoWeirdness =
|
|
(this.numberingSystem === null || this.numberingSystem === "latn") &&
|
|
(this.outputCalendar === null || this.outputCalendar === "gregory");
|
|
return isActuallyEn && hasNoWeirdness ? "en" : "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({ ...alts, defaultToEN: true });
|
|
}
|
|
|
|
redefaultToSystem(alts = {}) {
|
|
return this.clone({ ...alts, defaultToEN: false });
|
|
}
|
|
|
|
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", hourCycle: "h12" };
|
|
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 problematic. 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 = {}) {
|
|
// this forcesimple option is never used (the only caller short-circuits on it, but it seems safer to leave)
|
|
// (in contrast, the rest of the condition is used heavily)
|
|
return new PolyNumberFormatter(this.intl, opts.forceSimple || this.fastNumbers, opts);
|
|
}
|
|
|
|
dtFormatter(dt, intlOpts = {}) {
|
|
return new PolyDateFormatter(dt, this.intl, intlOpts);
|
|
}
|
|
|
|
relFormatter(opts = {}) {
|
|
return new PolyRelFormatter(this.intl, this.isEnglish(), opts);
|
|
}
|
|
|
|
listFormatter(opts = {}) {
|
|
return getCachedLF(this.intl, opts);
|
|
}
|
|
|
|
isEnglish() {
|
|
return (
|
|
this.locale === "en" ||
|
|
this.locale.toLowerCase() === "en-us" ||
|
|
new Intl.DateTimeFormat(this.intl).resolvedOptions().locale.startsWith("en-us")
|
|
);
|
|
}
|
|
|
|
equals(other) {
|
|
return (
|
|
this.locale === other.locale &&
|
|
this.numberingSystem === other.numberingSystem &&
|
|
this.outputCalendar === other.outputCalendar
|
|
);
|
|
}
|
|
}
|