/* * Utilities: A classic collection of JavaScript utilities * Copyright 2112 Matthew Eernisse (mde@fleegix.org) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ var string = require('./string') , date , log = require('./log'); /** @name date @namespace date */ date = new (function () { var _this = this , _date = new Date(); var _US_DATE_PAT = /^(\d{1,2})(?:\-|\/|\.)(\d{1,2})(?:\-|\/|\.)(\d{4})/; var _DATETIME_PAT = /^(\d{4})(?:\-|\/|\.)(\d{1,2})(?:\-|\/|\.)(\d{1,2})(?:T| )?(\d{2})?(?::)?(\d{2})?(?::)?(\d{2})?(?:\.)?(\d+)?(?: *)?(Z|[+-]\d{4}|[+-]\d{2}:\d{2}|[+-]\d{2})?/; // TODO Add am/pm parsing instead of dumb, 24-hour clock. var _TIME_PAT = /^(\d{1,2})?(?::)?(\d{2})?(?::)?(\d{2})?(?:\.)?(\d+)?$/; var _dateMethods = [ 'FullYear' , 'Month' , 'Date' , 'Hours' , 'Minutes' , 'Seconds' , 'Milliseconds' ]; var _isArray = function (obj) { return obj && typeof obj === 'object' && typeof obj.length === 'number' && typeof obj.splice === 'function' && !(obj.propertyIsEnumerable('length')); }; this.weekdayLong = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; this.weekdayShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; this.monthLong = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; this.monthShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; this.meridiem = { 'AM': 'AM', 'PM': 'PM' } // compat this.meridian = this.meridiem /** @name date#supportedFormats @public @object @description List of supported strftime formats */ this.supportedFormats = { // abbreviated weekday name according to the current locale 'a': function (dt) { return _this.weekdayShort[dt.getDay()]; }, // full weekday name according to the current locale 'A': function (dt) { return _this.weekdayLong[dt.getDay()]; }, // abbreviated month name according to the current locale 'b': function (dt) { return _this.monthShort[dt.getMonth()]; }, 'h': function (dt) { return _this.strftime(dt, '%b'); }, // full month name according to the current locale 'B': function (dt) { return _this.monthLong[dt.getMonth()]; }, // preferred date and time representation for the current locale 'c': function (dt) { return _this.strftime(dt, '%a %b %d %T %Y'); }, // century number (the year divided by 100 and truncated // to an integer, range 00 to 99) 'C': function (dt) { return _this.calcCentury(dt.getFullYear());; }, // day of the month as a decimal number (range 01 to 31) 'd': function (dt) { return string.lpad(dt.getDate(), '0', 2); }, // same as %m/%d/%y 'D': function (dt) { return _this.strftime(dt, '%m/%d/%y') }, // day of the month as a decimal number, a single digit is // preceded by a space (range ' 1' to '31') 'e': function (dt) { return string.lpad(dt.getDate(), ' ', 2); }, // month as a decimal number, a single digit is // preceded by a space (range ' 1' to '12') 'f': function () { return _this.strftimeNotImplemented('f'); }, // same as %Y-%m-%d 'F': function (dt) { return _this.strftime(dt, '%Y-%m-%d'); }, // like %G, but without the century. 'g': function () { return _this.strftimeNotImplemented('g'); }, // The 4-digit year corresponding to the ISO week number // (see %V). This has the same format and value as %Y, // except that if the ISO week number belongs to the // previous or next year, that year is used instead. 'G': function () { return _this.strftimeNotImplemented('G'); }, // hour as a decimal number using a 24-hour clock (range // 00 to 23) 'H': function (dt) { return string.lpad(dt.getHours(), '0', 2); }, // hour as a decimal number using a 12-hour clock (range // 01 to 12) 'I': function (dt) { return string.lpad( _this.hrMil2Std(dt.getHours()), '0', 2); }, // day of the year as a decimal number (range 001 to 366) 'j': function (dt) { return string.lpad( _this.calcDays(dt), '0', 3); }, // Hour as a decimal number using a 24-hour clock (range // 0 to 23 (space-padded)) 'k': function (dt) { return string.lpad(dt.getHours(), ' ', 2); }, // Hour as a decimal number using a 12-hour clock (range // 1 to 12 (space-padded)) 'l': function (dt) { return string.lpad( _this.hrMil2Std(dt.getHours()), ' ', 2); }, // month as a decimal number (range 01 to 12) 'm': function (dt) { return string.lpad((dt.getMonth()+1), '0', 2); }, // minute as a decimal number 'M': function (dt) { return string.lpad(dt.getMinutes(), '0', 2); }, // Linebreak 'n': function () { return '\n'; }, // either `am' or `pm' according to the given time value, // or the corresponding strings for the current locale 'p': function (dt) { return _this.getMeridian(dt.getHours()); }, // time in a.m. and p.m. notation 'r': function (dt) { return _this.strftime(dt, '%I:%M:%S %p'); }, // time in 24 hour notation 'R': function (dt) { return _this.strftime(dt, '%H:%M'); }, // second as a decimal number 'S': function (dt) { return string.lpad(dt.getSeconds(), '0', 2); }, // Tab char 't': function () { return '\t'; }, // current time, equal to %H:%M:%S 'T': function (dt) { return _this.strftime(dt, '%H:%M:%S'); }, // weekday as a decimal number [1,7], with 1 representing // Monday 'u': function (dt) { return _this.convertOneBase(dt.getDay()); }, // week number of the current year as a decimal number, // starting with the first Sunday as the first day of the // first week 'U': function () { return _this.strftimeNotImplemented('U'); }, // week number of the year (Monday as the first day of the // week) as a decimal number [01,53]. If the week containing // 1 January has four or more days in the new year, then it // is considered week 1. Otherwise, it is the last week of // the previous year, and the next week is week 1. 'V': function () { return _this.strftimeNotImplemented('V'); }, // week number of the current year as a decimal number, // starting with the first Monday as the first day of the // first week 'W': function () { return _this.strftimeNotImplemented('W'); }, // day of the week as a decimal, Sunday being 0 'w': function (dt) { return dt.getDay(); }, // preferred date representation for the current locale // without the time 'x': function (dt) { return _this.strftime(dt, '%D'); }, // preferred time representation for the current locale // without the date 'X': function (dt) { return _this.strftime(dt, '%T'); }, // year as a decimal number without a century (range 00 to // 99) 'y': function (dt) { return _this.getTwoDigitYear(dt.getFullYear()); }, // year as a decimal number including the century 'Y': function (dt) { return string.lpad(dt.getFullYear(), '0', 4); }, // time zone or name or abbreviation 'z': function () { return _this.strftimeNotImplemented('z'); }, 'Z': function () { return _this.strftimeNotImplemented('Z'); }, // Literal percent char '%': function (dt) { return '%'; } }; /** @name date#getSupportedFormats @public @function @description return the list of formats in a string @return {String} The list of supported formats */ this.getSupportedFormats = function () { var str = ''; for (var i in this.supportedFormats) { str += i; } return str; } this.supportedFormatsPat = new RegExp('%[' + this.getSupportedFormats() + ']{1}', 'g'); /** @name date#strftime @public @function @return {String} The `dt` formated with the given `format` @description Formats the given date with the strftime formated @param {Date} dt the date object to format @param {String} format the format to convert the date to */ this.strftime = function (dt, format) { if (!dt) { return '' } var d = dt; var pats = []; var dts = []; var str = format; var key; // Allow either Date obj or UTC stamp d = typeof dt == 'number' ? new Date(dt) : dt; // Grab all instances of expected formats into array while (pats = this.supportedFormatsPat.exec(format)) { dts.push(pats[0]); } // Process any hits for (var i = 0; i < dts.length; i++) { key = dts[i].replace(/%/, ''); str = str.replace('%' + key, this.supportedFormats[key](d)); } return str; }; this.strftimeNotImplemented = function (s) { throw('this.strftime format "' + s + '" not implemented.'); }; /** @name date#calcCentury @public @function @return {String} The century for the given date @description Find the century for the given `year` @param {Number} year The year to find the century for */ this.calcCentury = function (year) { if(!year) { year = _date.getFullYear(); } var ret = parseInt((year / 100) + 1); year = year.toString(); // If year ends in 00 subtract one, because it's still the century before the one // it divides to if (year.substring(year.length - 2) === '00') { ret--; } return ret.toString(); }; /** @name date#calcDays @public @function @return {Number} The number of days so far for the given date @description Calculate the day number in the year a particular date is on @param {Date} dt The date to use */ this.calcDays = function (dt) { var first = new Date(dt.getFullYear(), 0, 1); var diff = 0; var ret = 0; first = first.getTime(); diff = (dt.getTime() - first); ret = parseInt(((((diff/1000)/60)/60)/24))+1; return ret; }; /** * Adjust from 0-6 base week to 1-7 base week * @param d integer for day of week * @return Integer day number for 1-7 base week */ this.convertOneBase = function (d) { return d == 0 ? 7 : d; }; this.getTwoDigitYear = function (yr) { // Add a millenium to take care of years before the year 1000, // (e.g, the year 7) since we're only taking the last two digits // If we overshoot, it doesn't matter var millenYear = yr + 1000; var str = millenYear.toString(); str = str.substr(2); // Get the last two digits return str }; /** @name date#getMeridiem @public @function @return {String} Return 'AM' or 'PM' based on hour in 24-hour format @description Return 'AM' or 'PM' based on hour in 24-hour format @param {Number} h The hour to check */ this.getMeridiem = function (h) { return h > 11 ? this.meridiem.PM : this.meridiem.AM; }; // Compat this.getMeridian = this.getMeridiem; /** @name date#hrMil2Std @public @function @return {String} Return a 12 hour version of the given time @description Convert a 24-hour formatted hour to 12-hour format @param {String} hour The hour to convert */ this.hrMil2Std = function (hour) { var h = typeof hour == 'number' ? hour : parseInt(hour); var str = h > 12 ? h - 12 : h; str = str == 0 ? 12 : str; return str; }; /** @name date#hrStd2Mil @public @function @return {String} Return a 24 hour version of the given time @description Convert a 12-hour formatted hour with meridian flag to 24-hour format @param {String} hour The hour to convert @param {Boolean} pm hour is PM then this should be true */ this.hrStd2Mil = function (hour, pm) { var h = typeof hour == 'number' ? hour : parseInt(hour); var str = ''; // PM if (pm) { str = h < 12 ? (h+12) : h; } // AM else { str = h == 12 ? 0 : h; } return str; }; // Constants for use in this.add var dateParts = { YEAR: 'year' , MONTH: 'month' , DAY: 'day' , HOUR: 'hour' , MINUTE: 'minute' , SECOND: 'second' , MILLISECOND: 'millisecond' , QUARTER: 'quarter' , WEEK: 'week' , WEEKDAY: 'weekday' }; // Create a map for singular/plural lookup, e.g., day/days var datePartsMap = {}; for (var p in dateParts) { datePartsMap[dateParts[p]] = dateParts[p]; datePartsMap[dateParts[p] + 's'] = dateParts[p]; } this.dateParts = dateParts; /** @name date#add @public @function @return {Date} Incremented date @description Add to a Date in intervals of different size, from milliseconds to years @param {Date} dt Date (or timestamp Number), date to increment @param {String} interv a constant representing the interval, e.g. YEAR, MONTH, DAY. See this.dateParts @param {Number} incr how much to add to the date */ this.add = function (dt, interv, incr) { if (typeof dt == 'number') { dt = new Date(dt); } function fixOvershoot() { if (sum.getDate() < dt.getDate()) { sum.setDate(0); } } var key = datePartsMap[interv]; var sum = new Date(dt); switch (key) { case dateParts.YEAR: sum.setFullYear(dt.getFullYear()+incr); // Keep increment/decrement from 2/29 out of March fixOvershoot(); break; case dateParts.QUARTER: // Naive quarter is just three months incr*=3; // fallthrough... case dateParts.MONTH: sum.setMonth(dt.getMonth()+incr); // Reset to last day of month if you overshoot fixOvershoot(); break; case dateParts.WEEK: incr*=7; // fallthrough... case dateParts.DAY: sum.setDate(dt.getDate() + incr); break; case dateParts.WEEKDAY: //FIXME: assumes Saturday/Sunday weekend, but even this is not fixed. // There are CLDR entries to localize this. var dat = dt.getDate(); var weeks = 0; var days = 0; var strt = 0; var trgt = 0; var adj = 0; // Divide the increment time span into weekspans plus leftover days // e.g., 8 days is one 5-day weekspan / and two leftover days // Can't have zero leftover days, so numbers divisible by 5 get // a days value of 5, and the remaining days make up the number of weeks var mod = incr % 5; if (mod == 0) { days = (incr > 0) ? 5 : -5; weeks = (incr > 0) ? ((incr-5)/5) : ((incr+5)/5); } else { days = mod; weeks = parseInt(incr/5); } // Get weekday value for orig date param strt = dt.getDay(); // Orig date is Sat / positive incrementer // Jump over Sun if (strt == 6 && incr > 0) { adj = 1; } // Orig date is Sun / negative incrementer // Jump back over Sat else if (strt == 0 && incr < 0) { adj = -1; } // Get weekday val for the new date trgt = strt + days; // New date is on Sat or Sun if (trgt == 0 || trgt == 6) { adj = (incr > 0) ? 2 : -2; } // Increment by number of weeks plus leftover days plus // weekend adjustments sum.setDate(dat + (7*weeks) + days + adj); break; case dateParts.HOUR: sum.setHours(sum.getHours()+incr); break; case dateParts.MINUTE: sum.setMinutes(sum.getMinutes()+incr); break; case dateParts.SECOND: sum.setSeconds(sum.getSeconds()+incr); break; case dateParts.MILLISECOND: sum.setMilliseconds(sum.getMilliseconds()+incr); break; default: // Do nothing break; } return sum; // Date }; /** @name date#diff @public @function @return {Number} number of (interv) units apart that the two dates are @description Get the difference in a specific unit of time (e.g., number of months, weeks, days, etc.) between two dates. @param {Date} date1 First date to check @param {Date} date2 Date to compate `date1` with @param {String} interv a constant representing the interval, e.g. YEAR, MONTH, DAY. See this.dateParts */ this.diff = function (date1, date2, interv) { // date1 // Date object or Number equivalent // // date2 // Date object or Number equivalent // // interval // A constant representing the interval, e.g. YEAR, MONTH, DAY. See this.dateParts. // Accept timestamp input if (typeof date1 == 'number') { date1 = new Date(date1); } if (typeof date2 == 'number') { date2 = new Date(date2); } var yeaDiff = date2.getFullYear() - date1.getFullYear(); var monDiff = (date2.getMonth() - date1.getMonth()) + (yeaDiff * 12); var msDiff = date2.getTime() - date1.getTime(); // Millisecs var secDiff = msDiff/1000; var minDiff = secDiff/60; var houDiff = minDiff/60; var dayDiff = houDiff/24; var weeDiff = dayDiff/7; var delta = 0; // Integer return value var key = datePartsMap[interv]; switch (key) { case dateParts.YEAR: delta = yeaDiff; break; case dateParts.QUARTER: var m1 = date1.getMonth(); var m2 = date2.getMonth(); // Figure out which quarter the months are in var q1 = Math.floor(m1/3) + 1; var q2 = Math.floor(m2/3) + 1; // Add quarters for any year difference between the dates q2 += (yeaDiff * 4); delta = q2 - q1; break; case dateParts.MONTH: delta = monDiff; break; case dateParts.WEEK: // Truncate instead of rounding // Don't use Math.floor -- value may be negative delta = parseInt(weeDiff); break; case dateParts.DAY: delta = dayDiff; break; case dateParts.WEEKDAY: var days = Math.round(dayDiff); var weeks = parseInt(days/7); var mod = days % 7; // Even number of weeks if (mod == 0) { days = weeks*5; } else { // Weeks plus spare change (< 7 days) var adj = 0; var aDay = date1.getDay(); var bDay = date2.getDay(); weeks = parseInt(days/7); mod = days % 7; // Mark the date advanced by the number of // round weeks (may be zero) var dtMark = new Date(date1); dtMark.setDate(dtMark.getDate()+(weeks*7)); var dayMark = dtMark.getDay(); // Spare change days -- 6 or less if (dayDiff > 0) { switch (true) { // Range starts on Sat case aDay == 6: adj = -1; break; // Range starts on Sun case aDay == 0: adj = 0; break; // Range ends on Sat case bDay == 6: adj = -1; break; // Range ends on Sun case bDay == 0: adj = -2; break; // Range contains weekend case (dayMark + mod) > 5: adj = -2; break; default: // Do nothing break; } } else if (dayDiff < 0) { switch (true) { // Range starts on Sat case aDay == 6: adj = 0; break; // Range starts on Sun case aDay == 0: adj = 1; break; // Range ends on Sat case bDay == 6: adj = 2; break; // Range ends on Sun case bDay == 0: adj = 1; break; // Range contains weekend case (dayMark + mod) < 0: adj = 2; break; default: // Do nothing break; } } days += adj; days -= (weeks*2); } delta = days; break; case dateParts.HOUR: delta = houDiff; break; case dateParts.MINUTE: delta = minDiff; break; case dateParts.SECOND: delta = secDiff; break; case dateParts.MILLISECOND: delta = msDiff; break; default: // Do nothing break; } // Round for fractional values and DST leaps return Math.round(delta); // Number (integer) }; /** @name date#parse @public @function @return {Date} a JavaScript Date object @description Convert various sorts of strings to JavaScript Date objects @param {String} val The string to convert to a Date */ this.parse = function (val, options) { var dt , opts = options || {} , matches , reordered , off , posOff , offHours , offMinutes , offSeconds , curr , stamp , utc; // Yay, we have a date, use it as-is if (val instanceof Date || typeof val.getFullYear == 'function') { dt = val; } // Timestamp? else if (typeof val == 'number') { dt = new Date(val); } // String or Array else { // Value preparsed, looks like [yyyy, mo, dd, hh, mi, ss, ms, (offset?)] if (_isArray(val)) { matches = val; matches.unshift(null); matches[8] = null; } // Oh, crap, it's a string -- parse this bitch else if (typeof val == 'string') { matches = val.match(_DATETIME_PAT); // Stupid US-only format? if (!matches) { matches = val.match(_US_DATE_PAT); if (matches) { reordered = [matches[0], matches[3], matches[1], matches[2]]; // Pad the results to the same length as ISO8601 reordered[8] = null; matches = reordered; } } // Time-stored-in-Date hack? if (!matches) { matches = val.match(_TIME_PAT); if (matches) { reordered = [matches[0], 0, 1, 0, matches[1], matches[2], matches[3], matches[4], null]; matches = reordered; } } } // Sweet, the regex actually parsed it into something useful if (matches) { matches.shift(); // First match is entire match, DO NOT WANT off = matches.pop(); // If there's an offset (or the 'Z' non-offset offset), use UTC // methods to set everything if (off) { if (off == 'Z') { utc = true; offSeconds = 0; } else { utc = false; // Convert from extended to basic if necessary off = off.replace(/:/g, ''); // '+0000' will still be zero if (parseInt(off, 10) === 0) { utc = true; } else { posOff = off.indexOf('+') === 0; // Strip plus or minus off = off.substr(1); offHours = parseInt(off.substr(0, 2), 10); offMinutes = off.substr(2, 2); if (offMinutes) { offMinutes = parseInt(offMinutes, 10); } else { offMinutes = 0; } offSeconds = off.substr(4, 2); if (offSeconds) { offSeconds = parseInt(offSeconds, 10); } else { offSeconds = 0; } offSeconds += (offMinutes * 60) offSeconds += (offHours * 60 * 60); if (!posOff) { offSeconds = 0 - offSeconds; } } } } dt = new Date(0); // Stupid zero-based months matches[1] = parseInt(matches[1], 10) - 1; // Specific offset, iterate the array and set each date property // using UTC setters, then adjust time using offset if (off) { for (var i = matches.length - 1; i > -1; i--) { curr = parseInt(matches[i], 10) || 0; dt['setUTC' + _dateMethods[i]](curr); } // Add any offset dt.setSeconds(dt.getSeconds() - offSeconds); } // Otherwise we know nothing about the offset, just iterate the // array and set each date property using regular setters else { var lastValIndex; for (var i = matches.length - 1; i > -1; i--) { if (matches[i]) { curr = parseInt(matches[i], 10); if (typeof lastValIndex == 'undefined') { lastValIndex = i; } } else { curr = 0; } dt['set' + _dateMethods[i]](curr); } if (opts.setMax) { for (var i = lastValIndex + 1, ii = matches.length; i < ii; i++) { switch (i) { case 3: dt['set' + _dateMethods[i]](23); break; case 4: case 5: dt['set' + _dateMethods[i]](59); break; case 6: dt.setMilliseconds(999); break; } } } } } // Shit, last-ditch effort using Date.parse else { stamp = Date.parse(val); // Failures to parse yield NaN if (!isNaN(stamp)) { dt = new Date(stamp); } } } return dt || null; }; /** @name date#relativeTime @public @function @return {String} A string describing the amount of time ago the passed-in Date is @description Convert a Date to an English sentence representing how long ago the Date was @param {Date} dt The Date to to convert to a relative time string @param {Object} [opts] @param {Boolean} [opts.abbreviated=false] Use short strings (e.g., '<1m') for the relative-time string */ this.relativeTime = function (dt, options) { var opts = options || {} , now = opts.now || new Date() , abbr = opts.abbreviated || false , format = opts.format || '%F %T' // Diff in seconds , diff = (now.getTime() - dt.getTime()) / 1000 , ret , num , hour = 60*60 , day = 24*hour , week = 7*day , month = 30*day; switch (true) { case diff < 60: ret = abbr ? '<1m' : 'less than a minute ago'; break; case diff < 120: ret = abbr ? '1m' : 'about a minute ago'; break; case diff < (45*60): num = parseInt((diff / 60), 10); ret = abbr ? num + 'm' : num + ' minutes ago'; break; case diff < (2*hour): ret = abbr ? '1h' : 'about an hour ago'; break; case diff < (1*day): num = parseInt((diff / hour), 10); ret = abbr ? num + 'h' : 'about ' + num + ' hours ago'; break; case diff < (2*day): ret = abbr ? '1d' : 'one day ago'; break; case diff < (7*day): num = parseInt((diff / day), 10); ret = abbr ? num + 'd' : 'about ' + num + ' days ago'; break; case diff < (11*day): ret = abbr ? '1w': 'one week ago'; break; case diff < (1*month): num = Math.round(diff / week); ret = abbr ? num + 'w' : 'about ' + num + ' weeks ago'; break; default: ret = date.strftime(dt, format); break; } return ret; }; /** @name date#toISO8601 @public @function @return {String} A string describing the amount of time ago @description Convert a Date to an ISO8601-formatted string @param {Date} dt The Date to to convert to an ISO8601 string */ var _pad = function (n) { return n < 10 ? '0' + n : n; }; this.toISO8601 = function (dt, options) { var opts = options || {} , off = dt.getTimezoneOffset() , offHours , offMinutes , str = this.strftime(dt, '%F') + 'T' + this.strftime(dt, '%T') + '.' + string.lpad(dt.getMilliseconds(), '0', 3); if (opts.tz) { // Pos and neg numbers are both truthy; only // zero is falsy if (off && !opts.utc) { str += off > 0 ? '-' : '+'; offHours = parseInt(off / 60, 10); str += string.lpad(offHours, '0', 2); offMinutes = off % 60; if (offMinutes) { str += string.lpad(offMinutes, '0', 2); } } else { str += 'Z'; } } return str; }; // Alias this.toIso8601 = this.toISO8601; this.toUTC = function (dt) { return new Date( dt.getUTCFullYear() , dt.getUTCMonth() , dt.getUTCDate() , dt.getUTCHours() , dt.getUTCMinutes() , dt.getUTCSeconds() , dt.getUTCMilliseconds()); }; })(); module.exports = date;