UNPKG

strtime

Version:

Comprehensive strftime and strptime implementation.

1,535 lines (1,496 loc) 52.2 kB
// github.com/pineapplemachine/strtime-js // MIT license, Copyright (c) 2018 Sophie Kirschner (sophiek@pineapplemachine.com) // References: // https://www.ibm.com/support/knowledgecenter/en/ssw_ibm_i_71/rtref/strpti.htm // https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html // https://www.gnu.org/software/libc/manual/html_node/Low_002dLevel-Time-String-Parsing.html // http://man7.org/linux/man-pages/man3/strptime.3.html // https://apidock.com/ruby/DateTime/strftime // http://strftime.org/ function getFormatOptions(timezone, options){ let useOptions; let tz = undefined; if( timezone === null || timezone === undefined || Number.isFinite(timezone) || typeof(timezone) === "string" ){ tz = timezone; useOptions = options || {}; }else if(timezone && !options){ useOptions = timezone; tz = useOptions.tz; }else{ useOptions = {}; } return { tz: tz, options: useOptions, }; } function getTimezoneOffsetMinutes(date, tz){ if(tz === null || tz === undefined){ return 0; }else if(tz >= -16 && tz <= +16){ return Math.floor(60 * tz); }else if(Number.isFinite(tz)){ return Math.floor(tz); }else if(tz === "local"){ return -(date || new Date()).getTimezoneOffset(); }else{ const tzUpper = String(tz).toUpperCase(); if(tzUpper in defaultTimezoneNames){ const offset = Math.floor(60 * defaultTimezoneNames[tzUpper]); if(Number.isFinite(offset)){ return offset; } } } throw new Error(`Unrecognized timezone option "${tz}".`); } function strftime(date, format, timezone, options){ if(Number.isFinite(date)){ // Accept unix timestamps (milliseconds since epoch) date = new Date(date); }else if(!date){ throw new Error("No date input was provided."); }else if(typeof(date.toDate) === "function"){ // Support date objects from https://www.npmjs.com/package/moment // Support date objects from https://www.npmjs.com/package/dayjs date = date.toDate(); }else if(typeof(date.toJSDate) === "function"){ // Support date objects from https://www.npmjs.com/package/luxon date = date.toJSDate(); } if(!(date instanceof Date)){ throw new Error("Failed to get Date instance from date input."); } const tokens = TimestampParser.parseFormatString(format); const useOptions = getFormatOptions(timezone, options); const timezoneOffsetMinutes = getTimezoneOffsetMinutes(date, useOptions.tz); const tzDate = new Date(date); if(timezoneOffsetMinutes !== undefined){ tzDate.setUTCMinutes( date.getUTCMinutes() + timezoneOffsetMinutes ); } let output = ""; for(let token of tokens){ if(token instanceof Directive){ output += token.write(tzDate, "", useOptions.options, timezoneOffsetMinutes); }else if(token instanceof Directive.Token){ output += token.write(tzDate, useOptions.options, timezoneOffsetMinutes); }else{ output += token; } } return output; } function strptime(timestamp, format, timezone, options){ const useOptions = getFormatOptions(timezone, options); const parser = new TimestampParser(timestamp, format); const timezoneOffsetMinutes = getTimezoneOffsetMinutes(undefined, useOptions.tz); if(timezoneOffsetMinutes !== undefined){ parser.timezoneOffsetMinutes = timezoneOffsetMinutes; } if(useOptions.options){ for(let key in useOptions.options){ parser[key] = useOptions.options[key]; } } const result = parser.parse(); return result.getDate(); } const strtime = { strftime: strftime, strptime: strptime, }; const english = { eraNames: [ "CE", "BCE" ], meridiemNames: [ "AM", "PM" ], shortWeekdayNames: [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ], longWeekdayNames: [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ], shortMonthNames: [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ], longMonthNames: [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ], ordinalTransform: function(number){ const digit = Math.floor(number % 10); if(number > 3 && number <= 20) return `${number}th`; if(digit === 1) return `${number}st`; else if(digit === 2) return `${number}nd`; else if(digit === 3) return `${number}rd`; else return `${number}th`; }, }; function leftPad(char, length, text){ let string = String(text); while(string.length < length){ string = char + string; } return string; } function writeTimezoneOffset(offsetMinutes, modifier){ const absOffset = Math.abs(offsetMinutes); const sign = (offsetMinutes >= 0 ? "+" : "-"); const hour = leftPad("0", 2, Math.floor(absOffset / 60)); const minute = leftPad("0", 2, absOffset % 60); if(modifier === "::"){ return sign + hour + ":" + minute + ":00"; }else if(modifier === ":"){ return sign + hour + ":" + minute; }else{ return sign + hour + minute; } } // Get the day of the week given an input Date. // Returns 0 for Sunday, 1 for Monday, etc. // https://www.quora.com/How-does-Tomohiko-Sakamotos-Algorithm-work/answer/Raziman-T-V?srid=u2HNX function getDayOfWeek(date){ const offsets = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; let year = date.getUTCFullYear(); let month = date.getUTCMonth(); let day = date.getUTCDate(); if(month < 2){ year--; } return ( offsets[month] + year + day + Math.floor(year / 4) - Math.floor(year / 100) + Math.floor(year / 400) ) % 7; } // Get the day of the year as a number (1-366) function getDayOfYear(date){ const lengths = monthLengths.forYear(date.getUTCFullYear()); const months = lengths.slice(0, date.getUTCMonth()); return date.getUTCDate() + ( (months.length && months.reduce((a, b) => a + b)) || 0 ); } // Get the week of the year (starting with Sunday) (0-53) function getWeekOfYearFromSunday(date){ const dayOfYear = getDayOfYear(date); const firstDayOfWeek = getFirstWeekdayInYear(date.getUTCFullYear()); return Math.floor((dayOfYear + (firstDayOfWeek || 7) - 1) / 7); } // Get the week of the year (starting with Monday) (0-53) function getWeekOfYearFromMonday(date){ const dayOfYear = getDayOfYear(date); const dayOfWeek = getDayOfWeek(date); const firstDayOfWeek = getFirstWeekdayInYear(date.getUTCFullYear()); const sundayWeek = Math.floor((dayOfYear + (firstDayOfWeek || 7) - 1) / 7); return sundayWeek - (dayOfWeek === 0 ? 1 : 0) + (firstDayOfWeek === 1 ? 1 : 0); } function getISOWeeksInYear(year){ const priorYear = year - 1; const a = (year + Math.floor(year / 4) - Math.floor(year / 100) + Math.floor(year / 400) ) % 7; const b = (priorYear + Math.floor(priorYear / 4) - Math.floor(priorYear / 100) + Math.floor(priorYear / 400) ) % 7; return a === 4 || b === 3 ? 53 : 52; } // Get the ISO week of the year // https://en.wikipedia.org/wiki/ISO_week_date // https://en.wikipedia.org/wiki/ISO_8601#Week_dates function getISOWeekOfYear(date){ const year = date.getUTCFullYear(); const dayOfYear = getDayOfYear(date); const dayOfWeek = getDayOfWeek(date); const weekNumber = Math.floor((10 + dayOfYear - (dayOfWeek || 7)) / 7); if(weekNumber < 1){ return getISOWeeksInYear(year - 1); }else if(weekNumber > getISOWeeksInYear(year)){ return 1; }else{ return weekNumber; } } // https://en.wikipedia.org/wiki/ISO_week_date function getISOWeekDateYear(date){ const year = date.getUTCFullYear(); const dayOfYear = getDayOfYear(date); const dayOfWeek = getDayOfWeek(date); const weekNumber = Math.floor((10 + dayOfYear - (dayOfWeek || 7)) / 7); if(weekNumber < 1){ return year - 1; }else if(weekNumber > getISOWeeksInYear(year)){ return year + 1; }else{ return year; } } class Directive{ constructor(options){ // List of possible names for this directive this.names = options.names; // Pad numbers to this length (normally) this.padLength = options.padLength; // A likely (but not strict) length to be used when resolving // ambiguous parsing inputs this.likelyLength = options.likelyLength; // Indicates that this directive uses text (as opposed to numbers) this.text = options.text; // The minimum permitted numeric value for a directive this.minimum = options.minimum; // The maximum permitted numeric value for a directive this.maximum = options.maximum; // Whether this directive represents a number that can be negative this.canBeNegative = options.canBeNegative; // This directive should always be rewritten using a combination of // other directives this.rewrite = options.rewrite; // Function to parse content from a string this.parseFunction = options.parse; // Function to write content as a string, given a date to format this.writeFunction = options.write; // Function to store a parsed numeric value this.storeFunction = options.store; } static getByName(name){ for(let directive of Directive.list){ if(directive.names.indexOf(name) >= 0){ return directive; } } return undefined; } isOrdinal(){ return false; } getCanBeNegative(){ return this.canBeNegative; } getLikelyLength(){ return this.likelyLength; } getRewriteParsed(parseFormatString){ if(!this.rewriteParsedValue){ this.rewriteParsedValue = parseFormatString(this.rewrite); for(let token of this.rewriteParsedValue){ token.expandedFrom = this; } } return this.rewriteParsedValue; } hasParseFunction(){ return !!this.parseFunction; } hasStoreFunction(){ return !!this.storeFunction; } parse(parser){ return this.parseFunction.call(parser); } store(parser, number){ this.storeFunction.call(parser, number); } write(date, modifier, options, timezoneOffsetMinutes){ return this.writeFunction(date, modifier, options, timezoneOffsetMinutes); } numberInBounds(value){ return ( (!Number.isFinite(this.minimum) || value >= this.minimum) && (!Number.isFinite(this.maximum) || value <= this.maximum) ); } getBoundsString(){ if(Number.isFinite(this.minimum) && Number.isFinite(this.maximum)){ return `[${this.minimum}, ${this.maximum}]`; }else if(Number.isFinite(this.minimum)){ return `[${this.minimum}, ...]`; }else if(Number.isFinite(this.maximum)){ return `[..., ${this.maximum}]`; }else{ return undefined; } } toString(){ return "%" + this.names[0]; } } Directive.Token = class DirectiveToken{ constructor(modifier, directive){ this.modifier = modifier; this.directive = directive; this.expandedFrom = undefined; } isOrdinal(){ return this.modifier === ":"; } getCanBeNegative(){ return this.directive.canBeNegative; } getLikelyLength(){ return this.directive.likelyLength; } hasParseFunction(){ return this.directive.hasParseFunction(); } hasStoreFunction(){ return this.directive.hasStoreFunction(); } parse(parser){ return this.directive.parseFunction.call(parser, this.modifier); } store(parser, number){ this.directive.storeFunction.call(parser, number); } write(date, options, timezoneOffsetMinutes){ const result = this.directive.write(date, this.modifier, options, timezoneOffsetMinutes); if(this.modifier === "^"){ const resultString = String(result); if(typeof(result) === "number") return resultString; const upper = resultString.toUpperCase(); return upper !== resultString ? upper : resultString.toLowerCase(); }else if(this.modifier === "_" && this.directive.padLength){ return leftPad(" ", this.directive.padLength, result); }else if(this.modifier === "_" && this.directive.text){ return String(result).toLowerCase(); }else if(this.modifier === "-" && this.directive.padLength){ return String(result); }else if(this.modifier === ":" && !this.directive.text){ const transform = ((options && options.ordinalTransform) || english.ordinalTransform ); return transform(result); }else if(!this.directive.text && this.directive.padLength){ return (result >= 0 ? leftPad("0", this.directive.padLength, result) : `-${leftPad("0", this.directive.padLength, -result)}` ); }else{ return String(result); } } numberInBounds(value){ return this.directive.numberInBounds(value); } getBoundsString(){ return this.directive.getBoundsString(); } toString(){ return "%" + this.modifier + this.directive.names[0]; } } Directive.StringToken = class DirectiveStringToken{ constructor(string){ this.string = string || ""; this.expandedFrom = undefined; } addCharacter(ch){ this.string += ch; } toString(){ return this.string; } } Directive.list = [ // Abbreviated weekday name new Directive({ names: ["a"], text: true, parse: function(){ this.dayOfWeek = this.parseWeekdayName(this); }, write: function(date, modifier, options){ const names = ((options && options.shortWeekdayNames) || english.shortWeekdayNames ); return names[date.getUTCDay() % 7]; }, }), // Long weekday name new Directive({ names: ["A"], text: true, parse: function(){ this.dayOfWeek = this.parseWeekdayName(this); }, write: function(date, modifier, options){ const names = ((options && options.longWeekdayNames) || english.longWeekdayNames ); return names[date.getUTCDay() % 7]; }, }), // Abbreviated month name new Directive({ names: ["b", "h"], text: true, parse: function(){ this.month = 1 + this.parseMonthName(this); }, write: function(date, modifier, options){ const names = ((options && options.shortMonthNames) || english.shortMonthNames ); return names[date.getUTCMonth() % 12]; }, }), // Long month name new Directive({ names: ["B"], text: true, parse: function(){ this.month = 1 + this.parseMonthName(this); }, write: function(date, modifier, options){ const names = ((options && options.longMonthNames) || english.longMonthNames ); return names[date.getUTCMonth() % 12]; }, }), // Combination date and time, same as "%a %b %e %H:%M:%S %Y" new Directive({ names: ["c"], rewrite: "%a %b %e %H:%M:%S %Y", }), // Century (complements %y) new Directive({ names: ["C"], likelyLength: 2, canBeNegative: true, store: function(number){ this.century = number; }, write: function(date){ return Math.floor(date.getUTCFullYear() / 100); }, }), // Two-digit day of month new Directive({ names: ["d"], padLength: 2, likelyLength: 2, minimum: 1, maximum: 31, store: function(number){ this.dayOfMonth = number; }, write: function(date){ return date.getUTCDate(); }, }), // Same as %m/%d/%y new Directive({ names: ["D", "x"], rewrite: "%m/%d/%y", }), // Day of month padded with spaces (same as "%_d") new Directive({ names: ["e"], likelyLength: 2, minimum: 1, maximum: 31, store: function(number){ this.dayOfMonth = number; }, write: function(date, modifier){ if(!modifier){ return leftPad(" ", 2, date.getUTCDate()); }else{ return date.getUTCDate(); } }, }), // Six-digit microsecond new Directive({ names: ["f"], padLength: 6, likelyLength: 6, minimum: 0, maximum: 999999, store: function(number){ this.microsecond = number; }, write: function(date){ return 1000 * date.getUTCMilliseconds(); }, }), // Same as %Y-%m-%d new Directive({ names: ["F"], rewrite: "%Y-%m-%d", }), // Two-digit ISO week year new Directive({ names: ["g"], likelyLength: 2, store: function(number){ this.isoTwoDigitYear = number; }, write: function(date){ return getISOWeekDateYear(date) % 100; }, }), // Full ISO week year new Directive({ names: ["G"], padLength: 4, likelyLength: 4, canBeNegative: true, store: function(number){ this.isoYear = number; }, write: function(date){ return getISOWeekDateYear(date); }, }), // Two-digit hour (0-23) new Directive({ names: ["H", "k"], padLength: 2, likelyLength: 2, minimum: 0, maximum: 23, store: function(number){ this.hour = number; }, write: function(date){ return date.getUTCHours(); }, }), // Two-digit hour (1-12) to be used in combination with %p (AM/PM) new Directive({ names: ["I", "l"], padLength: 2, likelyLength: 2, minimum: 1, maximum: 12, store: function(number){ this.hour = number; }, write: function(date){ return (date.getUTCHours() % 12) || 12; }, }), // Day in year new Directive({ names: ["j"], padLength: 3, likelyLength: 3, minimum: 1, maximum: 366, store: function(number){ this.dayOfYear = number; }, write: function(date){ return getDayOfYear(date); }, }), // Three-digit millisecond new Directive({ names: ["L"], padLength: 3, likelyLength: 3, minimum: 0, maximum: 999, store: function(number){ this.millisecond = number; }, write: function(date){ return date.getUTCMilliseconds(); }, }), // Two-digit month number (1-12) new Directive({ names: ["m"], padLength: 2, likelyLength: 2, minimum: 1, maximum: 12, store: function(number){ this.month = number; }, write: function(date){ return 1 + date.getUTCMonth(); }, }), // Two-digit minute (0-59) new Directive({ names: ["M"], padLength: 2, likelyLength: 2, minimum: 0, maximum: 59, store: function(number){ this.minute = number; }, write: function(date){ return date.getUTCMinutes(); }, }), // AM or PM (uppercase) new Directive({ names: ["p"], text: true, parse: function(){ this.meridiem = this.parseMeridiemName(); }, write: function(date, modifier, options){ const index = date.getUTCHours() < 12 ? 0 : 1; return ( (options && options.meridiemNames) || english.meridiemNames )[index]; }, }), // AM or PM (lowercase) new Directive({ names: ["P"], likelyLength: 2, text: true, parse: function(){ this.meridiem = this.parseMeridiemName(); }, write: function(date, modifier, options){ const index = date.getUTCHours() < 12 ? 0 : 1; return ( (options && options.meridiemNames) || english.meridiemNames )[index].toLowerCase(); }, }), // Number of mircoseconds since epoch new Directive({ names: ["Q"], canBeNegative: true, store: function(number){ this.microsecondsSinceEpoch = number; }, write: function(date){ return Math.floor(date.getTime() * 1000); }, }), // Same as "%I:%M:%S %p" new Directive({ names: ["r"], rewrite: "%I:%M:%S %p", }), // Same as "%H:%M" new Directive({ names: ["R"], rewrite: "%H:%M", }), // Number of seconds since epoch new Directive({ names: ["s"], canBeNegative: true, store: function(number){ this.secondsSinceEpoch = number; }, write: function(date){ return Math.floor(date.getTime() / 1000); }, }), // Two-digit second (0-61) new Directive({ names: ["S"], padLength: 2, likelyLength: 2, minimum: 0, maximum: 61, store: function(number){ this.second = number; }, write: function(date){ return Math.min(59, date.getUTCSeconds()); }, }), // Same as %H:%M:%S new Directive({ names: ["T", "X"], rewrite: "%H:%M:%S", }), // Weekday number, starting with Monday (1-7) new Directive({ names: ["u"], likelyLength: 1, minimum: 1, maximum: 7, store: function(number){ this.dayOfWeek = number % 7; }, write: function(date){ return getDayOfWeek(date) || 7; }, }), // Week of the year, starting with Sunday (0-53) new Directive({ names: ["U"], padLength: 2, likelyLength: 2, minimum: 0, maximum: 53, store: function(number){ this.weekOfYearFromSunday = number; }, write: function(date){ return getWeekOfYearFromSunday(date); }, }), // VMS date, same as "%e-%b-%Y" new Directive({ names: ["v"], rewrite: "%e-%b-%Y", }), // ISO 8601:1988 week number (1-53), complements %g/%G new Directive({ names: ["V"], padLength: 2, likelyLength: 2, minimum: 1, maximum: 53, store: function(number){ this.isoWeekOfYear = number; }, write: function(date){ return getISOWeekOfYear(date); }, }), // Weekday number, starting with Sunday (0-6) new Directive({ names: ["w"], likelyLength: 1, minimum: 0, maximum: 6, store: function(number){ this.dayOfWeek = number % 7; }, write: function(date){ return getDayOfWeek(date); }, }), // Week of the year, starting with Monday (0-53) new Directive({ names: ["W"], padLength: 2, likelyLength: 2, minimum: 0, maximum: 53, store: function(number){ this.weekOfYearFromMonday = number; }, write: function(date){ return getWeekOfYearFromMonday(date); }, }), // Two-digit year new Directive({ names: ["y"], padLength: 2, likelyLength: 2, store: function(number){ this.twoDigitYear = number; }, write: function(date){ return date.getUTCFullYear() % 100; }, }), // Full year (usually four-digit, but not strictly so) new Directive({ names: ["Y"], padLength: 4, likelyLength: 4, canBeNegative: true, store: function(number){ this.year = number; }, write: function(date, modifier){ const year = date.getUTCFullYear(); // Modifier "^" produces unsigned year, for combination with era "%#" if(year <= 0 && modifier === "^") return 1 - year; else return year; }, }), // Timezone offset e.g. +0500 or -1200 new Directive({ names: ["z"], text: true, parse: function(modifier){ this.timezoneOffsetMinutes = this.parseTimezoneOffset(modifier); }, write: function(date, modifier, options, timezoneOffsetMinutes){ const offset = (Number.isFinite(timezoneOffsetMinutes) ? timezoneOffsetMinutes : -date.getTimezoneOffset() ); return writeTimezoneOffset(offset, modifier); }, }), // Timezone offset or name e.g. UTC or GMT or EST or +0500 or -1200 new Directive({ names: ["Z"], likelyLength: 5, text: true, parse: function(modifier){ const tzList = this.getTimezoneNameList(); const index = this.parseIndexInList(tzList); if(index !== undefined){ this.timezoneOffsetMinutes = 60 * this.timezoneNames[tzList[index]]; }else{ this.timezoneOffsetMinutes = this.parseTimezoneOffset(modifier); } }, write: function(date, modifier, options, timezoneOffsetMinutes){ const offset = (Number.isFinite(timezoneOffsetMinutes) ? timezoneOffsetMinutes : -date.getTimezoneOffset() ); if(offset === 0) return "UTC"; else return writeTimezoneOffset(offset, modifier); }, }), // Same as "%a %b %e %H:%M:%S %Z %Y" new Directive({ names: ["+"], rewrite: "%a %b %e %H:%M:%S %Z %Y", }), // Era (BC/BCE) new Directive({ names: ["#"], text: true, parse: function(){ this.era = this.parseEraName(); }, write: function(date, modifier, options){ const index = date.getUTCFullYear() <= 0 ? 1 : 0; return ( (options && options.eraNames) || english.eraNames )[index]; }, }), ]; // The assertion-error package was used as a basis for the TimestampParseError type // https://github.com/chaijs/assertion-error/blob/master/index.js // The constructor function TimestampParseError(reason, parser){ Error.call(this); this.reason = reason; this.format = parser.format; this.timestamp = parser.string; this.token = parser.currentToken; this.index = parser.index; if(this.token && this.token.expandedFrom && this.index !== undefined) this.message = ( `Failed to parse token "${this.token}" ` + `(expanded from "${this.token.expandedFrom}") at position [${this.index}] ` + `in timestamp "${this.timestamp}" with format "${this.format}": ` + `${this.reason}` ); else if(this.token && this.index !== undefined) this.message = ( `Failed to parse token "${this.token}" at position [${this.index}] ` + `in timestamp "${this.timestamp}" with format "${this.format}": ` + `${this.reason}` ); else if(this.token) this.message = ( `Failed to parse token "${this.token}" ` + `in format "${this.format}": ${this.reason}` ); else this.message = ( `Failed with format "${this.format}": ${this.reason}` ); // https://nodejs.org/api/errors.html#errors_error_capturestacktrace_targetobject_constructoropt if(Error.captureStackTrace){ Error.captureStackTrace(this, this.constructor); }else{ try{ throw new Error(); }catch(error){ this.stack = error.stack; } } } // Prototype wrangling TimestampParseError.prototype = Object.create(Error.prototype); TimestampParseError.prototype.name = "TimestampParseError"; TimestampParseError.prototype.constructor = TimestampParseError; function isDigit(ch){ return ( ch === "0" || ch === "1" || ch === "2" || ch === "3" || ch === "4" || ch === "5" || ch === "6" || ch === "7" || ch === "8" || ch === "9" ); } // Matches GNU C strptime behavior // https://www.gnu.org/software/libc/manual/html_node/Low_002dLevel-Time-String-Parsing.html function getYearFromTwoDigits(year){ return year + (year <= 68 ? 2000 : 1900); } function getMonthFromDayOfYear(year, dayOfYear){ const months = monthLengths.forYear(year); let days = 0; for(let i = 0; i < months.length; i++){ if(days >= dayOfYear) return i; days += months[i]; } return 12; } function getDayOfMonthFromDayOfYear(year, dayOfYear){ const months = monthLengths.forYear(year); let days = 0; for(let i = 0; i < months.length; i++){ if(dayOfYear - days <= months[i]) return dayOfYear - days; days += months[i]; } return dayOfYear - days; } // https://en.wikipedia.org/wiki/ISO_week_date // https://en.wikipedia.org/wiki/ISO_8601#Week_dates function getDateFromISOWeekDate(parser, isoYear, isoWeekOfYear, dayOfWeek){ const firstDayOfWeek = getFirstWeekdayInYear(isoYear); const weekdayOfJan4 = ((3 + firstDayOfWeek) % 7) || 7; const daysInYear = isLeapYear(isoYear) ? 366 : 365; let dayOfYear = 7 * isoWeekOfYear + (dayOfWeek || 7) - weekdayOfJan4 - 3; if(dayOfYear < 1){ parser.year = isoYear - 1; dayOfYear += daysInYear; }else if(dayOfYear > daysInYear){ parser.year = 1 + isoYear; dayOfYear -= daysInYear; }else{ parser.year = isoYear; } parser.month = getMonthFromDayOfYear(parser.year, dayOfYear); parser.dayOfMonth = getDayOfMonthFromDayOfYear(parser.year, dayOfYear); } function getDateFromSundayWeekDate(parser, year, weekOfYear, dayOfWeek){ const firstDayOfWeek = getFirstWeekdayInYear(year); const dayOfYear = 1 + 7 * weekOfYear - (firstDayOfWeek || 7) + dayOfWeek; parser.year = year; parser.month = getMonthFromDayOfYear(year, dayOfYear); parser.dayOfMonth = getDayOfMonthFromDayOfYear(year, dayOfYear); } function getDateFromMondayWeekDate(parser, year, weekOfYear, dayOfWeek){ const firstDayOfWeek = getFirstWeekdayInYear(year); const sundayDayOfYear = 1 + 7 * weekOfYear - (firstDayOfWeek || 7) + dayOfWeek; const dayOfYear = sundayDayOfYear + ( (dayOfWeek === 0 ? 7 : 0) - (firstDayOfWeek === 1 ? 7 : 0) ); parser.year = year; parser.month = getMonthFromDayOfYear(year, dayOfYear); parser.dayOfMonth = getDayOfMonthFromDayOfYear(year, dayOfYear); } class TimestampParser{ constructor(timestamp, format, tokens){ // Parser state this.index = 0; this.string = String(timestamp); this.format = String(format); this.tokens = tokens || TimestampParser.parseFormatString(this.format); this.forkLength = 0; this.currentToken = undefined; // Parser settings this.shortWeekdayNames = english.shortWeekdayNames; this.longWeekdayNames = english.longWeekdayNames; this.shortMonthNames = english.shortMonthNames; this.longMonthNames = english.longMonthNames; this.eraNames = english.eraNames; this.meridiemNames = english.meridiemNames; this.ordinalTransform = english.ordinalTransform; this.timezoneNames = defaultTimezoneNames; // Storage values from parsing tokens this.era = undefined; this.century = undefined; this.year = undefined; this.twoDigitYear = undefined; this.isoYear = undefined; this.isoTwoDigitYear = undefined; this.month = undefined; this.isoWeekOfYear = undefined; this.weekOfYearFromSunday = undefined; this.weekOfYearFromMonday = undefined; this.dayOfYear = undefined; this.dayOfMonth = undefined; this.dayOfWeek = undefined; this.hour = undefined; this.minute = undefined; this.second = undefined; this.millisecond = undefined; this.microsecond = undefined; this.meridiem = undefined; this.timezoneOffsetMinutes = undefined; this.secondsSinceEpoch = undefined; this.millisecondsSinceEpoch = undefined; this.microsecondsSinceEpoch = undefined; } getTimezoneOffsetOfDate(date){ const offset = (this.timezoneOffsetMinutes === undefined ? -date.getTimezoneOffset() : this.timezoneOffsetMinutes ); const offsetSign = offset >= 0 ? +1 : -1; const absOffset = Math.abs(offset); return { hour: offsetSign * Math.floor(absOffset / 60), minute: offsetSign * Math.floor(absOffset % 60), totalMinutes: offset, }; } getDate(){ if(this.microsecondsSinceEpoch === undefined){ if(this.millisecondsSinceEpoch !== undefined){ this.microsecondsSinceEpoch = 1000 * this.millisecondsSinceEpoch; }else if(this.secondsSinceEpoch !== undefined){ this.microsecondsSinceEpoch = 1000000 * this.secondsSinceEpoch; } } if(this.microsecondsSinceEpoch !== undefined){ const date = new Date(this.microsecondsSinceEpoch / 1000); const offset = this.getTimezoneOffsetOfDate(date); date.setUTCMinutes(date.getUTCMinutes() - offset.totalMinutes); return date; } const date = new Date(); if(this.year === undefined && this.twoDigitYear !== undefined){ if(this.century === undefined){ this.year = getYearFromTwoDigits(this.twoDigitYear); }else{ this.year = 100 * this.century + this.twoDigitYear; } }else if(this.isoYear === undefined && this.isoTwoDigitYear !== undefined){ this.isoYear = getYearFromTwoDigits(this.isoTwoDigitYear); }else if(this.year === undefined && this.century !== undefined){ this.year = 100 * this.century; } if(this.era && this.year !== undefined){ this.year = 1 - this.year; } if(this.hour !== undefined && this.meridiem !== undefined){ this.hour = (this.hour % 12) + (this.meridiem ? 12 : 0); } if(this.microsecond === undefined && this.millisecond !== undefined){ this.microsecond = 1000 * this.millisecond; } if(this.isoYear !== undefined && this.isoWeekOfYear !== undefined && (this.month === undefined || this.dayOfMonth === undefined) ){ getDateFromISOWeekDate(this, this.isoYear, this.isoWeekOfYear, this.dayOfWeek || 0 ); }else if(this.dayOfYear !== undefined){ const year = this.year !== undefined ? this.year : date.getFullYear(); if(this.month === undefined){ this.month = getMonthFromDayOfYear(year, this.dayOfYear); } if(this.dayOfMonth === undefined){ this.dayOfMonth = getDayOfMonthFromDayOfYear(year, this.dayOfYear); } }else if(this.weekOfYearFromSunday !== undefined && (this.month === undefined || this.dayOfMonth === undefined) ){ const year = this.year !== undefined ? this.year : date.getFullYear(); getDateFromSundayWeekDate(this, year, this.weekOfYearFromSunday, this.dayOfWeek || 0 ); }else if(this.weekOfYearFromMonday !== undefined && (this.month === undefined || this.dayOfMonth === undefined) ){ const year = this.year !== undefined ? this.year : date.getFullYear(); getDateFromMondayWeekDate(this, year, this.weekOfYearFromMonday, this.dayOfWeek || 0 ); } if(this.year !== undefined){ date.setUTCFullYear(this.year); } if(this.month !== undefined){ // https://github.com/pineapplemachine/strtime-js/issues/5 // https://stackoverflow.com/questions/26681313/javascript-setutcmonth-does-not-work-for-november date.setUTCMonth(0, 1); date.setUTCMonth(this.month - 1); } if(this.dayOfMonth !== undefined){ date.setUTCDate(this.dayOfMonth); } const offset = this.getTimezoneOffsetOfDate(date); if(offset.totalMinutes){ this.hour = (this.hour || 0) - offset.hour; this.minute = (this.minute || 0) - offset.minute; } date.setUTCHours(this.hour || 0); date.setUTCMinutes(this.minute || 0); date.setUTCSeconds(this.second || 0); date.setUTCMilliseconds(this.microsecond ? this.microsecond / 1000 : 0); return date; } copy(){ const parser = new TimestampParser(this.string, this.format, this.tokens); for(let key in this){ parser[key] = this[key]; } return parser; } fork(length, startTokenIndex){ const parser = this.copy(); parser.forkLength = length; return parser.parse(startTokenIndex); } parse(startTokenIndex){ for(let i = startTokenIndex || 0; i < this.tokens.length; i++){ const token = this.tokens[i]; this.currentToken = token; if(this.index >= this.string.length) throw new TimestampParseError( "Timestamp is too short to match the whole format.", this ); if(token instanceof Directive.StringToken){ this.parseStringToken(token.string); }else if(token.hasParseFunction()){ token.parse(this); }else if(token.hasStoreFunction() && !token.text){ const next = this.tokens[1 + i]; if((next instanceof Directive.StringToken && isDigit(next.string[0])) || (( next instanceof Directive || next instanceof Directive.Token ) && !next.text)){ const result = this.parseAmbiguousNumber(token, i); if(result) return result; }else{ token.store(this, this.parseNumber(token)); } }else{ throw new TimestampParseError("Invalid directive.", this); } } this.currentToken = undefined; if(1 + this.index < this.string.length) throw new TimestampParseError( `Timestamp is too long for the given format. Text remaining: "${this.string.slice(this.index)}".`, this ); return this; } parseStringToken(token){ for(let i = 0; i < token.length; i++){ if(this.string[this.index] !== token[i]){ throw new TimestampParseError(`String literal "${token}" not matched.`, this); } this.index++; } } parseAmbiguousNumber(token, tokenIndex){ if(this.forkLength === 0){ const likelyLength = token.getLikelyLength(); if(likelyLength){ try{ return this.fork(likelyLength, tokenIndex); }catch(error){ if(!(error instanceof TimestampParseError)) throw error; } } let lastWholeError = undefined; for(let i = 1; i < this.string.length - this.index; i++){ if(i === token.likelyLength) continue; try{ return this.fork(i, tokenIndex); }catch(error){ if(!(error instanceof TimestampParseError)) throw error; if(error.message = "Timestamp is too short to match the whole format."){ lastWholeError = error; } } } if(lastWholeError){ throw lastWholeError; }else{ throw new TimestampParseError("Failed to parse ambiguous number.", this); } }else{ const number = this.parseNumber(token, this.forkLength); token.store(this, number); this.forkLength = 0; } } parseNumber(directive, lengthLimit = Infinity){ const negative = this.string[this.index] === "-"; if(negative && !directive.getCanBeNegative()){ throw new TimestampParseError("Number cannot be negative.", this); }else if(negative){ this.index++; } const start = this.index; while(this.index < this.string.length && this.string[this.index] === " " ){ this.index++; } while(this.index < this.string.length && this.index - start < lengthLimit && isDigit(this.string[this.index]) ){ this.index++; } const value = +this.string.slice(start, this.index).trim(); if(!Number.isInteger(value)){ throw new TimestampParseError("Failed to parse number.", this); }else if(!directive.numberInBounds(value)){ throw new TimestampParseError(`Number [${value}] is out of bounds ${directive.getBoundsString()}.`, this); } const result = negative ? -value : value; if(directive.isOrdinal()){ const ordinal = this.ordinalTransform(result); this.index += ordinal.length - (this.index - start); } return result; } parseMonthName(){ const names = this.shortMonthNames.slice(); names.push(...this.longMonthNames); const index = this.parseIndexInList(names); if(index === undefined) throw new TimestampParseError( "Failed to parse month name.", this ); return index % 12; } parseWeekdayName(){ const names = this.shortWeekdayNames.slice(); names.push(...this.longWeekdayNames); const index = this.parseIndexInList(names); if(index === undefined) throw new TimestampParseError( "Failed to parse weekday name.", this ); return index % 7; } parseMeridiemName(){ const index = this.parseIndexInList(this.meridiemNames); if(index === undefined) throw new TimestampParseError( "Failed to parse AM/PM.", this ); return index % 2; } parseEraName(){ const index = this.parseIndexInList(this.eraNames); if(index === undefined) throw new TimestampParseError( "Failed to parse era name.", this ); return index % 2; } parseIndexInList(list){ const possible = list.slice(); let possibleCount = possible.length; let matchIndex = undefined; let matchLength = 0; for(let i = 0; possibleCount && this.index + i < this.string.length; i++){ const ch = this.string[this.index + i].toLowerCase(); for(let j = 0; j < possible.length; j++){ const item = possible[j]; if(!item) continue; if(i >= item.length || item[i].toLowerCase() !== ch){ possible[j] = null; possibleCount--; }else if(1 + i === item.length){ matchIndex = j; matchLength = 1 + i; } } } if(matchIndex === undefined){ return undefined; }else{ this.index += matchLength; return matchIndex; } } getTimezoneNameList(){ if(!this.timezoneNameList){ this.timezoneNameList = []; for(let key in this.timezoneNames){ this.timezoneNameList.push(key); } } return this.timezoneNameList; } parseTimezoneOffset(modifier){ const start = this.index; const sign = this.string[this.index]; const hours = +this.string.slice(this.index + 1, this.index + 3); let minutes; if(this.string[this.index + 3] === ":"){ minutes = +this.string.slice(this.index + 4, this.index + 6); this.index += 6; }else{ minutes = +this.string.slice(this.index + 3, this.index + 5); this.index += 5; } if(!Number.isInteger(hours) || !Number.isInteger(minutes)){ throw new TimestampParseError( `Failed to parse timezone offset from string ` + `"${this.string.slice(start, this.index)}".`, this ); } const offset = minutes + 60 * hours; if(sign === "+" || sign === "±") return +offset; else if(sign === "-") return -offset; else throw new TimestampParseError(`Unknown timezone offset sign "${sign}".`, this); } } TimestampParser.parseFormatString = function parseFormatString(format){ const tokens = []; let directive = false; let modifier = undefined; const formatString = String(format); if(!formatString){ throw new TimestampParseError(`Empty format string.`, { format: formatString }); } function addCharacter(ch){ if(tokens.length && (tokens[tokens.length - 1] instanceof Directive.StringToken)){ if(isDigit(ch) === isDigit(tokens[tokens.length - 1].string[0])){ tokens[tokens.length - 1].addCharacter(ch); }else{ tokens.push(new Directive.StringToken(ch)); } }else{ tokens.push(new Directive.StringToken(ch)); } } for(let ch of formatString){ if(directive && ch === "%"){ addCharacter("%"); modifier = ""; directive = false; }else if(directive && ch === "n"){ addCharacter("\n"); modifier = ""; directive = false; }else if(directive && ch === "t"){ addCharacter("\t"); modifier = ""; directive = false; }else if(directive && !modifier && ( ch === "-" || ch === "_" || ch === "^" || ch === ":" )){ modifier += ch; }else if(directive){ const dir = Directive.getByName(ch); if(!dir) throw new TimestampParseError(`Unknown directive "%${modifier}${ch}".`, { format: formatString }); else if(dir.rewrite) tokens.push( ...dir.getRewriteParsed(TimestampParser.parseFormatString) ); else tokens.push( new Directive.Token(modifier, dir) ); modifier = ""; directive = false; }else if(ch === "%"){ modifier = ""; directive = true; }else{ addCharacter(ch); } } if(directive) throw new TimestampParseError( "Found unterminated directive at the end of the format string.", { format: formatString } ); if(tokens.length && tokens[tokens.length - 1].string === "Z"){ tokens.zuluTimezone = true; } return tokens; } function isLeapYear(year){ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } const monthLengths = { common: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], leap: [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], forYear: function(year){ return isLeapYear(year) ? monthLengths.leap : monthLengths.common; }, }; // https://stackoverflow.com/a/478992/4099022 function getFirstWeekdayInYear(year){ const y = year - 1; return (year * 365 + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) ) % 7; } const defaultTimezoneNames = { "ACDT": +10.5, "ACST": +9.5, "ACT": -5, "ACWST": +8.75, "ADT": -3, "AEDT": +11, "AEST": +10, "AFT": +4.5, "AKDT": -8, "AKST": -9, "AMST": -3, "AMT": -4, "AMT": +4, "ART": -3, "AST": +3, "AST": -4, "AWST": +8, "AZOST": 0, "AZOT": -1, "AZT": +4, "BDT": +8, "BIOT": +6, "BIT": -12, "BOT": -4, "BRST": -2, "BRT": -3, "BST": +6, "BST": +11, "BST": +1, "BTT": +6, "CAT": +2, "CCT": +6.5, "CDT": -5, "CDT": -4, "CEST": +2, "CET": +1, "CHADT": +13.75, "CHAST": +12.75, "CHOT": +8, "CHOST": +9, "CHST": +10, "CHUT": +10, "CIST": -8, "CIT": +8, "CKT": -10, "CLST": -3, "CLT": -4, "COST": -4, "COT": -5, "CST": -6, "CST": +8, "CST": -5, "CT": +8, "CVT": -1, "CWST": +8.75, "CXT": +7, "DAVT": +7, "DDUT": +10, "DFT": +1, "EASST": -5, "EAST": -6, "EAT": +3, "ECT": -4, "ECT": -5, "EDT": -4, "EEST": +3, "EET": +2, "EGST": 0, "EGT": -1, "EIT": +9, "EST":