PHP's strptime in JavaScript

How to use

You you can install via yarn add locutus and require this function via const strptime = require('locutus/php/datetime/strptime').

It is important to use a bundler that supports tree-shaking so that you only ship the functions that you actually use to your browser, instead of all of Locutus, which is massive. Examples are: Parcel, webpack, or rollup.js. For server-side use this is typically less of a concern.

Examples

Please note that these examples are distilled from test cases that automatically verify our functions still work correctly. This could explain some quirky ones.

#codeexpected result
1strptime('20091112222135', '%Y%m%d%H%M%S') // Return value will depend on date and locale{tm_sec: 35, tm_min: 21, tm_hour: 22, tm_mday: 12, tm_mon: 10, tm_year: 109, tm_wday: 4, tm_yday: 315, unparsed: ''}
2strptime('2009extra', '%Y'){tm_sec:0, tm_min:0, tm_hour:0, tm_mday:0, tm_mon:0, tm_year:109, tm_wday:3, tm_yday: -1, unparsed: 'extra'}

Here’s what our current JavaScript equivalent to PHP's strptime looks like.

module.exports = function strptime(dateStr, format) {
// discuss at: https://locutus.io/php/strptime/
// original by: Brett Zamir (https://brett-zamir.me)
// original by: strftime
// example 1: strptime('20091112222135', '%Y%m%d%H%M%S') // Return value will depend on date and locale
// returns 1: {tm_sec: 35, tm_min: 21, tm_hour: 22, tm_mday: 12, tm_mon: 10, tm_year: 109, tm_wday: 4, tm_yday: 315, unparsed: ''}
// example 2: strptime('2009extra', '%Y')
// returns 2: {tm_sec:0, tm_min:0, tm_hour:0, tm_mday:0, tm_mon:0, tm_year:109, tm_wday:3, tm_yday: -1, unparsed: 'extra'}

const setlocale = require('../strings/setlocale')
const arrayMap = require('../array/array_map')

const retObj = {
tm_sec: 0,
tm_min: 0,
tm_hour: 0,
tm_mday: 0,
tm_mon: 0,
tm_year: 0,
tm_wday: 0,
tm_yday: 0,
unparsed: '',
}
let i = 0
let j = 0
let amPmOffset = 0
let prevHour = false
const _reset = function (dateObj, realMday) {
// realMday is to allow for a value of 0 in return results (but without
// messing up the Date() object)
let jan1
const o = retObj
const d = dateObj
o.tm_sec = d.getUTCSeconds()
o.tm_min = d.getUTCMinutes()
o.tm_hour = d.getUTCHours()
o.tm_mday = realMday === 0 ? realMday : d.getUTCDate()
o.tm_mon = d.getUTCMonth()
o.tm_year = d.getUTCFullYear() - 1900
o.tm_wday = realMday === 0 ? (d.getUTCDay() > 0 ? d.getUTCDay() - 1 : 6) : d.getUTCDay()
jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
o.tm_yday = Math.ceil((d - jan1) / (1000 * 60 * 60 * 24))
}
const _date = function () {
const o = retObj
// We set date to at least 1 to ensure year or month doesn't go backwards
return _reset(
new Date(Date.UTC(o.tm_year + 1900, o.tm_mon, o.tm_mday || 1, o.tm_hour, o.tm_min, o.tm_sec)),
o.tm_mday,
)
}

const _NWS = /\S/
const _WS = /\s/

const _aggregates = {
c: 'locale',
D: '%m/%d/%y',
F: '%y-%m-%d',
r: 'locale',
R: '%H:%M',
T: '%H:%M:%S',
x: 'locale',
X: 'locale',
}

/* Fix: Locale alternatives are supported though not documented in PHP; see https://linux.die.net/man/3/strptime
Ec
EC
Ex
EX
Ey
EY
Od or Oe
OH
OI
Om
OM
OS
OU
Ow
OW
Oy
*/
const _pregQuote = function (str) {
return (str + '').replace(/([\\.+*?[^\]$(){}=!<>|:])/g, '\\$1')
}

// ensure setup of localization variables takes place
setlocale('LC_ALL', 0)

const $global = typeof window !== 'undefined' ? window : global
$global.$locutus = $global.$locutus || {}
const $locutus = $global.$locutus
const locale = $locutus.php.localeCategories.LC_TIME
const lcTime = $locutus.php.locales[locale].LC_TIME

// First replace aggregates (run in a loop because an agg may be made up of other aggs)
while (format.match(/%[cDFhnrRtTxX]/)) {
format = format.replace(/%([cDFhnrRtTxX])/g, function (m0, m1) {
const f = _aggregates[m1]
return f === 'locale' ? lcTime[m1] : f
})
}

const _addNext = function (j, regex, cb) {
if (typeof regex === 'string') {
regex = new RegExp('^' + regex, 'i')
}
const check = dateStr.slice(j)
const match = regex.exec(check)
// Even if the callback returns null after assigning to the
// return object, the object won't be saved anyways
const testNull = match ? cb.apply(null, match) : null
if (testNull === null) {
throw new Error('No match in string')
}
return j + match[0].length
}

const _addLocalized = function (j, formatChar, category) {
// Could make each parenthesized instead and pass index to callback:
return _addNext(j, arrayMap(_pregQuote, lcTime[formatChar]).join('|'), function (m) {
const match = lcTime[formatChar].search(new RegExp('^' + _pregQuote(m) + '$', 'i'))
if (match) {
retObj[category] = match[0]
}
})
}

// BEGIN PROCESSING CHARACTERS
for (i = 0, j = 0; i < format.length; i++) {
if (format.charAt(i) === '%') {
const literalPos = ['%', 'n', 't'].indexOf(format.charAt(i + 1))
if (literalPos !== -1) {
if (['%', '\n', '\t'].indexOf(dateStr.charAt(j)) === literalPos) {
// a matched literal
++i
// skip beyond
++j
continue
}
// Format indicated a percent literal, but not actually present
return false
}
var formatChar = format.charAt(i + 1)
try {
switch (formatChar) {
case 'a':
case 'A':
// Sunday-Saturday
// Changes nothing else
j = _addLocalized(j, formatChar, 'tm_wday')
break
case 'h':
case 'b':
// Jan-Dec
j = _addLocalized(j, 'b', 'tm_mon')
// Also changes wday, yday
_date()
break
case 'B':
// January-December
j = _addLocalized(j, formatChar, 'tm_mon')
// Also changes wday, yday
_date()
break
case 'C':
// 0+; century (19 for 20th)
// PHP docs say two-digit, but accepts one-digit (two-digit max):
j = _addNext(
j,
/^\d?\d/,

function (d) {
const year = (parseInt(d, 10) - 19) * 100
retObj.tm_year = year
_date()
if (!retObj.tm_yday) {
retObj.tm_yday = -1
}
// Also changes wday; and sets yday to -1 (always?)
},
)
break
case 'd':
case 'e':
// 1-31 day
j = _addNext(j, formatChar === 'd' ? /^(0[1-9]|[1-2]\d|3[0-1])/ : /^([1-2]\d|3[0-1]|[1-9])/, function (d) {
const dayMonth = parseInt(d, 10)
retObj.tm_mday = dayMonth
// Also changes w_day, y_day
_date()
})
break
case 'g':
// No apparent effect; 2-digit year (see 'V')
break
case 'G':
// No apparent effect; 4-digit year (see 'V')'
break
case 'H':
// 00-23 hours
j = _addNext(j, /^([0-1]\d|2[0-3])/, function (d) {
const hour = parseInt(d, 10)
retObj.tm_hour = hour
// Changes nothing else
})
break
case 'l':
case 'I':
// 01-12 hours
j = _addNext(j, formatChar === 'l' ? /^([1-9]|1[0-2])/ : /^(0[1-9]|1[0-2])/, function (d) {
const hour = parseInt(d, 10) - 1 + amPmOffset
retObj.tm_hour = hour
// Used for coordinating with am-pm
prevHour = true
// Changes nothing else, but affected by prior 'p/P'
})
break
case 'j':
// 001-366 day of year
j = _addNext(j, /^(00[1-9]|0[1-9]\d|[1-2]\d\d|3[0-6][0-6])/, function (d) {
const dayYear = parseInt(d, 10) - 1
retObj.tm_yday = dayYear
// Changes nothing else
// (oddly, since if original by a given year, could calculate other fields)
})
break
case 'm':
// 01-12 month
j = _addNext(j, /^(0[1-9]|1[0-2])/, function (d) {
const month = parseInt(d, 10) - 1
retObj.tm_mon = month
// Also sets wday and yday
_date()
})
break
case 'M':
// 00-59 minutes
j = _addNext(j, /^[0-5]\d/, function (d) {
const minute = parseInt(d, 10)
retObj.tm_min = minute
// Changes nothing else
})
break
case 'P':
// Seems not to work; AM-PM
// Could make fall-through instead since supposed to be a synonym despite PHP docs
return false
case 'p':
// am-pm
j = _addNext(j, /^(am|pm)/i, function (d) {
// No effect on 'H' since already 24 hours but
// works before or after setting of l/I hour
amPmOffset = /a/.test(d) ? 0 : 12
if (prevHour) {
retObj.tm_hour += amPmOffset
}
})
break
case 's':
// Unix timestamp (in seconds)
j = _addNext(j, /^\d+/, function (d) {
const timestamp = parseInt(d, 10)
const date = new Date(Date.UTC(timestamp * 1000))
_reset(date)
// Affects all fields, but can't be negative (and initial + not allowed)
})
break
case 'S':
// 00-59 seconds
j = _addNext(
j,
/^[0-5]\d/, // strptime also accepts 60-61 for some reason

function (d) {
const second = parseInt(d, 10)
retObj.tm_sec = second
// Changes nothing else
},
)
break
case 'u':
case 'w':
// 0 (Sunday)-6(Saturday)
j = _addNext(j, /^\d/, function (d) {
retObj.tm_wday = d - (formatChar === 'u')
// Changes nothing else apparently
})
break
case 'U':
case 'V':
case 'W':
// Apparently ignored (week of year, from 1st Monday)
break
case 'y':
// 69 (or higher) for 1969+, 68 (or lower) for 2068-
// PHP docs say two-digit, but accepts one-digit (two-digit max):
j = _addNext(
j,
/^\d?\d/,

function (d) {
d = parseInt(d, 10)
const year = d >= 69 ? d : d + 100
retObj.tm_year = year
_date()
if (!retObj.tm_yday) {
retObj.tm_yday = -1
}
// Also changes wday; and sets yday to -1 (always?)
},
)
break
case 'Y':
// 2010 (4-digit year)
// PHP docs say four-digit, but accepts one-digit (four-digit max):
j = _addNext(
j,
/^\d{1,4}/,

function (d) {
const year = parseInt(d, 10) - 1900
retObj.tm_year = year
_date()
if (!retObj.tm_yday) {
retObj.tm_yday = -1
}
// Also changes wday; and sets yday to -1 (always?)
},
)
break
case 'z':
// Timezone; on my system, strftime gives -0800,
// but strptime seems not to alter hour setting
break
case 'Z':
// Timezone; on my system, strftime gives PST, but strptime treats text as unparsed
break
default:
throw new Error('Unrecognized formatting character in strptime()')
}
} catch (e) {
if (e === 'No match in string') {
// Allow us to exit
// There was supposed to be a matching format but there wasn't
return false
}
// Calculate skipping beyond initial percent too
}
++i
} else if (format.charAt(i) !== dateStr.charAt(j)) {
// If extra whitespace at beginning or end of either, or between formats, no problem
// (just a problem when between % and format specifier)

// If the string has white-space, it is ok to ignore
if (dateStr.charAt(j).search(_WS) !== -1) {
j++
// Let the next iteration try again with the same format character
i--
} else if (format.charAt(i).search(_NWS) !== -1) {
// Any extra formatting characters besides white-space causes
// problems (do check after WS though, as may just be WS in string before next character)
return false
}
// Extra WS in format
// Adjust strings when encounter non-matching whitespace, so they align in future checks above
// Will check on next iteration (against same (non-WS) string character)
} else {
j++
}
}

// POST-PROCESSING
// Will also get extra whitespace; empty string if none
retObj.unparsed = dateStr.slice(j)
return retObj
}

A community effort

Not unlike Wikipedia, Locutus is an ongoing community effort. Our philosophy follows The McDonald’s Theory. This means that we assimilate first iterations with imperfections, hoping for others to take issue with-and improve them. This unorthodox approach has worked very well to foster fun and fruitful collaboration, but please be reminded to use our creations at your own risk. THE SOFTWARE IS PROVIDED "AS IS" has never been more true than for Locutus.

Now go and: [ View on GitHub | Edit on GitHub | View Raw ]


« More PHP datetime functions


Star