354 lines
13 KiB
JavaScript
354 lines
13 KiB
JavaScript
/*
|
|
* Copyright (C) 2013 - present Instructure, Inc.
|
|
*
|
|
* This file is part of Canvas.
|
|
*
|
|
* Canvas is free software: you can redistribute it and/or modify it under
|
|
* the terms of the GNU Affero General Public License as published by the Free
|
|
* Software Foundation, version 3 of the License.
|
|
*
|
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
* details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License along
|
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import _ from 'underscore'
|
|
import __tz from 'timezone/index'
|
|
import I18n from 'i18nObj'
|
|
import moment from 'moment'
|
|
import MomentFormats from 'moment_formats'
|
|
|
|
// start with the bare vendor-provided tz() function
|
|
let _tz = __tz
|
|
let currentLocale = 'en_US' // default to US locale
|
|
let momentLocale = 'en'
|
|
const _preloadedData =
|
|
window.__PRELOADED_TIMEZONE_DATA__ || (window.__PRELOADED_TIMEZONE_DATA__ = {})
|
|
|
|
// wrap it up in a set of methods that will always call the most up-to-date
|
|
// version. each method is intended to act as a subset of bigeasy's generic
|
|
// tz() functionality.
|
|
const tz = {
|
|
_preloadedData,
|
|
|
|
// wrap's moment() for parsing datetime strings. assumes the string to be
|
|
// parsed is in the profile timezone unless if contains an offset string
|
|
// *and* a format token to parse it, and unfudges the result.
|
|
moment(input, format) {
|
|
// ensure first argument is a string and second is a format or an array
|
|
// of formats
|
|
if (!_.isString(input) || !(_.isString(format) || _.isArray(format))) {
|
|
throw new Error(
|
|
'tz.moment only works on string+format(s). just use moment() directly for any other signature'
|
|
)
|
|
}
|
|
|
|
// call out to moment, leaving the result alone if invalid
|
|
let m = moment.apply(null, [input, format, momentLocale])
|
|
if (m._pf.unusedTokens.length > 0) {
|
|
// we didn't use strict at first, because we want to accept when
|
|
// there's unused input as long as we're using all tokens. but if the
|
|
// best non-strict match has unused tokens, reparse with strict
|
|
m = moment.apply(null, [input, format, momentLocale, true])
|
|
}
|
|
if (!m.isValid()) return m
|
|
|
|
// unfudge the result unless an offset was both specified and used in the
|
|
// parsed string.
|
|
//
|
|
// using moment internals here because I can't come up with any better
|
|
// reliable way to test for this :( fortunately, both _f and
|
|
// _pf.unusedTokens are always set as long as format is explicitly
|
|
// specified as either a string or array (which we've already checked
|
|
// for).
|
|
//
|
|
// _f lacking a 'Z' indicates that no offset token was specified in the
|
|
// format string used in parsing. we check this instead of just format in
|
|
// case format is an array, of which one contains a Z and the other
|
|
// doesn't, and we don't know until after parsing which format would best
|
|
// match the input.
|
|
//
|
|
// _pf.unusedTokens having a 'Z' token indicates that even though the
|
|
// format used contained a 'Z' token (since the first condition wasn't
|
|
// false), that token was not used during parsing; i.e. the input string
|
|
// didn't provide a value for it.
|
|
//
|
|
if (!m._f.match(/Z/) || m._pf.unusedTokens.indexOf('Z') >= 0) {
|
|
const l = m.locale()
|
|
m = moment(tz.raw_parse(m.locale('en').format('YYYY-MM-DD HH:mm:ss')))
|
|
m.locale(l)
|
|
}
|
|
|
|
return m
|
|
},
|
|
|
|
// interprets a date value (string, integer, Date, date array, etc. -- see
|
|
// bigeasy's tz() docs) according to _tz. returns null on parse failure.
|
|
// otherwise returns a Date (rather than _tz()'s timestamp integer)
|
|
// because, when treated correctly, they are interchangeable but the Date
|
|
// is more convenient.
|
|
raw_parse(value) {
|
|
const timestamp = _tz(value)
|
|
if (typeof timestamp === 'number') {
|
|
return new Date(timestamp)
|
|
}
|
|
return null
|
|
},
|
|
|
|
// parses a date value but more robustly. returns null on parse failure. if
|
|
// the value is a string but does not look like an ISO8601 string
|
|
// (loosely), or otherwise fails to be interpreted by raw_parse(), then
|
|
// parsing will be attempted with tz.moment() according to the formats
|
|
// defined in MomentFormats.getFormats(). also note that raw_parse('') will
|
|
// return the epoch, but parse('') will return null.
|
|
parse(value) {
|
|
// hard code '' and null as unparseable
|
|
if (value === '' || value == null) return null
|
|
|
|
if (!_.isString(value)) {
|
|
// try and understand the value through _tz. if it doesn't work, we
|
|
// don't know what else to do with it as a non-string
|
|
return tz.raw_parse(value)
|
|
}
|
|
|
|
// only try _tz with strings looking loosely like an ISO8601 value. in
|
|
// particular, we want to avoid parsing e.g. '2016' as 2,016 milliseconds
|
|
// since the epoch
|
|
if (value.match(/[-:]/)) {
|
|
const result = tz.raw_parse(value)
|
|
if (result) return result
|
|
}
|
|
|
|
// _tz parsing failed or skipped. try moment parsing
|
|
const formats = MomentFormats.getFormats()
|
|
const m = tz.moment(value, formats)
|
|
return m.isValid() ? m.toDate() : null
|
|
},
|
|
|
|
// format a date value (parsing it if necessary). returns null for parse
|
|
// failure on the value or an unrecognized format string.
|
|
format(value, format, otherZone) {
|
|
let localTz = _tz
|
|
const usingOtherZone = arguments.length === 3 && otherZone
|
|
if (usingOtherZone) {
|
|
if (!(otherZone in _preloadedData)) return null
|
|
localTz = _tz(_preloadedData[otherZone])
|
|
}
|
|
// make sure we have a good value first
|
|
const datetime = tz.parse(value)
|
|
if (datetime == null) return null
|
|
|
|
format = tz.adjustFormat(format)
|
|
|
|
// try and apply the format string to the datetime. if it succeeds, we'll
|
|
// get a string; otherwise we'll get the (non-string) date back.
|
|
let formatted = null
|
|
if (usingOtherZone) {
|
|
formatted = localTz(datetime, format, otherZone)
|
|
} else {
|
|
formatted = localTz(datetime, format)
|
|
}
|
|
|
|
if (typeof formatted !== 'string') return null
|
|
return formatted
|
|
},
|
|
|
|
adjustFormat(format) {
|
|
// translate recognized 'date.formats.*' and 'time.formats.*' to
|
|
// appropriate format strings according to locale.
|
|
if (format.match(/^(date|time)\.formats\./)) {
|
|
const locale_format = I18n.lookup(format)
|
|
if (locale_format) {
|
|
// in the process, turn %l, %k, and %e into %-l, %-k, and %-e
|
|
// (respectively) to avoid extra unnecessary space characters
|
|
//
|
|
// javascript doesn't have lookbehind, so do the fixing on the reversed
|
|
// string so we can use lookahead instead. the funky '(%%)*(?!%)' pattern
|
|
// in all the regexes is to make sure we match (once unreversed), e.g.,
|
|
// both %l and %%%l (literal-% + %l) but not %%l (literal-% + l).
|
|
format = locale_format
|
|
.split('')
|
|
.reverse()
|
|
.join('')
|
|
.replace(/([lke])(?=%(%%)*(?!%))/, '$1-')
|
|
.split('')
|
|
.reverse()
|
|
.join('')
|
|
}
|
|
}
|
|
|
|
// some locales may not (according to bigeasy's localization files) use
|
|
// an am/pm distinction, but could then be incorrectly used with 12-hour
|
|
// format strings (e.g. %l:%M%P), whether by erroneous format strings in
|
|
// canvas' localization files or by unlocalized format strings. as a
|
|
// result, you might get 3am and 3pm both formatting to the same value.
|
|
// to prevent this, 12-hour indicators with an am/pm indicator should be
|
|
// promoted to the equivalent 24-hour indicator when the locale defines
|
|
// %P as an empty string. ("reverse, look-ahead, reverse" pattern for
|
|
// same reason as above)
|
|
format = format
|
|
.split('')
|
|
.reverse()
|
|
.join('')
|
|
if (
|
|
!tz.hasMeridian() &&
|
|
((format.match(/[lI][-_]?%(%%)*(?!%)/) && format.match(/p%(%%)*(?!%)/i)) ||
|
|
format.match(/r[-_]?%(%%)*(?!%)/))
|
|
) {
|
|
format = format.replace(/l(?=[-_]?%(%%)*(?!%))/, 'k')
|
|
format = format.replace(/I(?=[-_]?%(%%)*(?!%))/, 'H')
|
|
format = format.replace(/r(?=[-_]?%(%%)*(?!%))/, 'T')
|
|
}
|
|
format = format
|
|
.split('')
|
|
.reverse()
|
|
.join('')
|
|
|
|
return format
|
|
},
|
|
|
|
hasMeridian() {
|
|
return _tz(new Date(), '%P') !== ''
|
|
},
|
|
|
|
useMeridian() {
|
|
if (!this.hasMeridian()) return false
|
|
const tiny = I18n.lookup('time.formats.tiny')
|
|
return tiny && tiny.match(/%-?l/)
|
|
},
|
|
|
|
// apply any number of non-format directives to the value (parsing it if
|
|
// necessary). return null for parse failure on the value or if one of the
|
|
// directives was mistakenly a format string. returns the modified Date
|
|
// otherwise. typical directives will be for date math, e.g. '-3 days'.
|
|
// non-format unrecognized directives are ignored.
|
|
shift(value) {
|
|
// make sure we have a good value first
|
|
const datetime = tz.parse(value)
|
|
if (datetime == null) return null
|
|
|
|
// no application strings given? just regurgitate the input (though
|
|
// parsed now).
|
|
if (arguments.length == 1) return datetime
|
|
|
|
// try and apply the directives to the datetime. if one was a format
|
|
// string (unacceptable) we'll get a (non-integer) string back.
|
|
// otherwise, we'll get a new timestamp integer back (even if some
|
|
// unrecognized non-format applications were ignored).
|
|
const args = [datetime].concat([].slice.apply(arguments, [1]))
|
|
const timestamp = _tz(...args)
|
|
if (typeof timestamp !== 'number') return null
|
|
return new Date(timestamp)
|
|
},
|
|
|
|
// allow snapshotting and restoration, and extending through the
|
|
// vendor-provided tz()'s functional composition
|
|
snapshot() {
|
|
return [_tz, currentLocale, momentLocale]
|
|
},
|
|
|
|
restore(snapshot) {
|
|
// we can't actually check that the snapshot has appropriate values, but
|
|
// we can at least verify the shape of [function, string, string]
|
|
if (!_.isArray(snapshot)) throw new Error('invalid tz() snapshot')
|
|
if (typeof snapshot[0] !== 'function') throw new Error('invalid tz() snapshot')
|
|
if (!_.isString(snapshot[1])) throw new Error('invalid tz() snapshot')
|
|
if (!_.isString(snapshot[2])) throw new Error('invalid tz() snapshot')
|
|
_tz = snapshot[0]
|
|
currentLocale = snapshot[1]
|
|
momentLocale = snapshot[2]
|
|
},
|
|
|
|
extendConfiguration() {
|
|
const extended = _tz(...arguments)
|
|
if (typeof extended !== 'function') throw new Error('invalid tz() extension')
|
|
_tz = extended
|
|
},
|
|
|
|
// apply a "feature" to tz (NOTE: persistent and shared). the provided
|
|
// feature can be a chunk of previously loaded data, which is applied
|
|
// immediately, or the name of a data file to load and then apply
|
|
// asynchronously.
|
|
applyFeature(data, name) {
|
|
function extendConfig(preloadedData) {
|
|
tz.extendConfiguration(preloadedData, name)
|
|
return Promise.resolve()
|
|
}
|
|
if (arguments.length > 1) {
|
|
this.preload(name, data)
|
|
return extendConfig(data)
|
|
}
|
|
|
|
name = data
|
|
const preloadedData = this.preload(name)
|
|
if (preloadedData instanceof Promise) {
|
|
return preloadedData.then(extendConfig)
|
|
}
|
|
return extendConfig(preloadedData)
|
|
},
|
|
|
|
// preload a specific data file without having to actually
|
|
// change the timezone to do it. Future "applyFeature" calls
|
|
// will apply synchronously if their data is already preloaded.
|
|
preload(name, data) {
|
|
if (arguments.length > 1) {
|
|
_preloadedData[name] = data
|
|
return _preloadedData[name]
|
|
} else if (_preloadedData[name]) {
|
|
return _preloadedData[name]
|
|
} else {
|
|
return new Promise((resolve, reject) =>
|
|
reject(
|
|
new Error(
|
|
`In webpack, loading timezones on-demand is not supported. ${name}" should already be script-tagged onto the page from Rails.`
|
|
)
|
|
)
|
|
)
|
|
}
|
|
},
|
|
|
|
changeLocale() {
|
|
if (arguments.length > 2) {
|
|
currentLocale = arguments[1]
|
|
momentLocale = arguments[2]
|
|
} else {
|
|
currentLocale = arguments[0]
|
|
momentLocale = arguments[1]
|
|
}
|
|
// take off the momentLocale before passing up the chain
|
|
const args = [].slice.apply(arguments).slice(0, arguments.length - 1)
|
|
return this.applyFeature.apply(this, args)
|
|
},
|
|
|
|
isMidnight(date) {
|
|
if (date == null) {
|
|
return false
|
|
}
|
|
return tz.format(date, '%R') === '00:00'
|
|
},
|
|
|
|
changeToTheSecondBeforeMidnight(date) {
|
|
return tz.parse(tz.format(date, '%F 23:59:59'))
|
|
},
|
|
|
|
setToEndOfMinute(date) {
|
|
return tz.parse(tz.format(date, '%F %R:59'))
|
|
},
|
|
|
|
// finds the given time of day on the given date ignoring dst conversion and such.
|
|
// e.g. if time is 2016-05-20 14:00:00 and date is 2016-03-17 23:59:59, the result will
|
|
// be 2016-03-17 14:00:00
|
|
mergeTimeAndDate(time, date) {
|
|
return tz.parse(tz.format(date, '%F ') + tz.format(time, '%T'))
|
|
}
|
|
}
|
|
|
|
// changing zone and locale are just aliases for applying a feature
|
|
tz.changeZone = tz.applyFeature
|
|
|
|
export default tz
|