fecha.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. const token = /d{1,4}|M{1,4}|YY(?:YY)?|S{1,3}|Do|ZZ|Z|([HhMsDm])\1?|[aA]|"[^"]*"|'[^']*'/g;
  2. const twoDigitsOptional = "\\d\\d?";
  3. const twoDigits = "\\d\\d";
  4. const threeDigits = "\\d{3}";
  5. const fourDigits = "\\d{4}";
  6. const word = "[^\\s]+";
  7. const literal = /\[([^]*?)\]/gm;
  8. type DateInfo = {
  9. year: number;
  10. month: number;
  11. day: number;
  12. hour: number;
  13. minute: number;
  14. second: number;
  15. millisecond: number;
  16. isPm: number | null;
  17. timezoneOffset: number | null;
  18. };
  19. export type I18nSettings = {
  20. amPm: [string, string];
  21. dayNames: Days;
  22. dayNamesShort: Days;
  23. monthNames: Months;
  24. monthNamesShort: Months;
  25. DoFn(dayOfMonth: number): string;
  26. };
  27. export type I18nSettingsOptional = Partial<I18nSettings>;
  28. export type Days = [string, string, string, string, string, string, string];
  29. export type Months = [
  30. string,
  31. string,
  32. string,
  33. string,
  34. string,
  35. string,
  36. string,
  37. string,
  38. string,
  39. string,
  40. string,
  41. string
  42. ];
  43. function shorten<T extends string[]>(arr: T, sLen: number): string[] {
  44. const newArr: string[] = [];
  45. for (let i = 0, len = arr.length; i < len; i++) {
  46. newArr.push(arr[i].substr(0, sLen));
  47. }
  48. return newArr;
  49. }
  50. const monthUpdate = (
  51. arrName: "monthNames" | "monthNamesShort" | "dayNames" | "dayNamesShort"
  52. ) => (v: string, i18n: I18nSettings): number | null => {
  53. const lowerCaseArr = i18n[arrName].map(v => v.toLowerCase());
  54. const index = lowerCaseArr.indexOf(v.toLowerCase());
  55. if (index > -1) {
  56. return index;
  57. }
  58. return null;
  59. };
  60. export function assign<A>(a: A): A;
  61. export function assign<A, B>(a: A, b: B): A & B;
  62. export function assign<A, B, C>(a: A, b: B, c: C): A & B & C;
  63. export function assign<A, B, C, D>(a: A, b: B, c: C, d: D): A & B & C & D;
  64. export function assign(origObj: any, ...args: any[]): any {
  65. for (const obj of args) {
  66. for (const key in obj) {
  67. // @ts-ignore ex
  68. origObj[key] = obj[key];
  69. }
  70. }
  71. return origObj;
  72. }
  73. const dayNames: Days = [
  74. "Sunday",
  75. "Monday",
  76. "Tuesday",
  77. "Wednesday",
  78. "Thursday",
  79. "Friday",
  80. "Saturday"
  81. ];
  82. const monthNames: Months = [
  83. "January",
  84. "February",
  85. "March",
  86. "April",
  87. "May",
  88. "June",
  89. "July",
  90. "August",
  91. "September",
  92. "October",
  93. "November",
  94. "December"
  95. ];
  96. const monthNamesShort: Months = shorten(monthNames, 3) as Months;
  97. const dayNamesShort: Days = shorten(dayNames, 3) as Days;
  98. const defaultI18n: I18nSettings = {
  99. dayNamesShort,
  100. dayNames,
  101. monthNamesShort,
  102. monthNames,
  103. amPm: ["am", "pm"],
  104. DoFn(dayOfMonth: number) {
  105. return (
  106. dayOfMonth +
  107. ["th", "st", "nd", "rd"][
  108. dayOfMonth % 10 > 3
  109. ? 0
  110. : ((dayOfMonth - (dayOfMonth % 10) !== 10 ? 1 : 0) * dayOfMonth) % 10
  111. ]
  112. );
  113. }
  114. };
  115. let globalI18n = assign({}, defaultI18n);
  116. const setGlobalDateI18n = (i18n: I18nSettingsOptional): I18nSettings =>
  117. (globalI18n = assign(globalI18n, i18n));
  118. const regexEscape = (str: string): string =>
  119. str.replace(/[|\\{()[^$+*?.-]/g, "\\$&");
  120. const pad = (val: string | number, len = 2): string => {
  121. val = String(val);
  122. while (val.length < len) {
  123. val = "0" + val;
  124. }
  125. return val;
  126. };
  127. const formatFlags: Record<
  128. string,
  129. (dateObj: Date, i18n: I18nSettings) => string
  130. > = {
  131. D: (dateObj: Date): string => String(dateObj.getDate()),
  132. DD: (dateObj: Date): string => pad(dateObj.getDate()),
  133. Do: (dateObj: Date, i18n: I18nSettings): string =>
  134. i18n.DoFn(dateObj.getDate()),
  135. d: (dateObj: Date): string => String(dateObj.getDay()),
  136. dd: (dateObj: Date): string => pad(dateObj.getDay()),
  137. ddd: (dateObj: Date, i18n: I18nSettings): string =>
  138. i18n.dayNamesShort[dateObj.getDay()],
  139. dddd: (dateObj: Date, i18n: I18nSettings): string =>
  140. i18n.dayNames[dateObj.getDay()],
  141. M: (dateObj: Date): string => String(dateObj.getMonth() + 1),
  142. MM: (dateObj: Date): string => pad(dateObj.getMonth() + 1),
  143. MMM: (dateObj: Date, i18n: I18nSettings): string =>
  144. i18n.monthNamesShort[dateObj.getMonth()],
  145. MMMM: (dateObj: Date, i18n: I18nSettings): string =>
  146. i18n.monthNames[dateObj.getMonth()],
  147. YY: (dateObj: Date): string =>
  148. pad(String(dateObj.getFullYear()), 4).substr(2),
  149. YYYY: (dateObj: Date): string => pad(dateObj.getFullYear(), 4),
  150. h: (dateObj: Date): string => String(dateObj.getHours() % 12 || 12),
  151. hh: (dateObj: Date): string => pad(dateObj.getHours() % 12 || 12),
  152. H: (dateObj: Date): string => String(dateObj.getHours()),
  153. HH: (dateObj: Date): string => pad(dateObj.getHours()),
  154. m: (dateObj: Date): string => String(dateObj.getMinutes()),
  155. mm: (dateObj: Date): string => pad(dateObj.getMinutes()),
  156. s: (dateObj: Date): string => String(dateObj.getSeconds()),
  157. ss: (dateObj: Date): string => pad(dateObj.getSeconds()),
  158. S: (dateObj: Date): string =>
  159. String(Math.round(dateObj.getMilliseconds() / 100)),
  160. SS: (dateObj: Date): string =>
  161. pad(Math.round(dateObj.getMilliseconds() / 10), 2),
  162. SSS: (dateObj: Date): string => pad(dateObj.getMilliseconds(), 3),
  163. a: (dateObj: Date, i18n: I18nSettings): string =>
  164. dateObj.getHours() < 12 ? i18n.amPm[0] : i18n.amPm[1],
  165. A: (dateObj: Date, i18n: I18nSettings): string =>
  166. dateObj.getHours() < 12
  167. ? i18n.amPm[0].toUpperCase()
  168. : i18n.amPm[1].toUpperCase(),
  169. ZZ(dateObj: Date): string {
  170. const offset = dateObj.getTimezoneOffset();
  171. return (
  172. (offset > 0 ? "-" : "+") +
  173. pad(Math.floor(Math.abs(offset) / 60) * 100 + (Math.abs(offset) % 60), 4)
  174. );
  175. },
  176. Z(dateObj: Date): string {
  177. const offset = dateObj.getTimezoneOffset();
  178. return (
  179. (offset > 0 ? "-" : "+") +
  180. pad(Math.floor(Math.abs(offset) / 60), 2) +
  181. ":" +
  182. pad(Math.abs(offset) % 60, 2)
  183. );
  184. }
  185. };
  186. type ParseInfo = [
  187. keyof DateInfo,
  188. string,
  189. ((v: string, i18n: I18nSettings) => number | null)?,
  190. string?
  191. ];
  192. const monthParse = (v: string): number => +v - 1;
  193. const emptyDigits: ParseInfo = [null, twoDigitsOptional];
  194. const emptyWord: ParseInfo = [null, word];
  195. const amPm: ParseInfo = [
  196. "isPm",
  197. word,
  198. (v: string, i18n: I18nSettings): number | null => {
  199. const val = v.toLowerCase();
  200. if (val === i18n.amPm[0]) {
  201. return 0;
  202. } else if (val === i18n.amPm[1]) {
  203. return 1;
  204. }
  205. return null;
  206. }
  207. ];
  208. const timezoneOffset: ParseInfo = [
  209. "timezoneOffset",
  210. "[^\\s]*?[\\+\\-]\\d\\d:?\\d\\d|[^\\s]*?Z?",
  211. (v: string): number | null => {
  212. const parts = (v + "").match(/([+-]|\d\d)/gi);
  213. if (parts) {
  214. const minutes = +parts[1] * 60 + parseInt(parts[2], 10);
  215. return parts[0] === "+" ? minutes : -minutes;
  216. }
  217. return 0;
  218. }
  219. ];
  220. const parseFlags: Record<string, ParseInfo> = {
  221. D: ["day", twoDigitsOptional],
  222. DD: ["day", twoDigits],
  223. Do: ["day", twoDigitsOptional + word, (v: string): number => parseInt(v, 10)],
  224. M: ["month", twoDigitsOptional, monthParse],
  225. MM: ["month", twoDigits, monthParse],
  226. YY: [
  227. "year",
  228. twoDigits,
  229. (v: string): number => {
  230. const now = new Date();
  231. const cent = +("" + now.getFullYear()).substr(0, 2);
  232. return +("" + (+v > 68 ? cent - 1 : cent) + v);
  233. }
  234. ],
  235. h: ["hour", twoDigitsOptional, undefined, "isPm"],
  236. hh: ["hour", twoDigits, undefined, "isPm"],
  237. H: ["hour", twoDigitsOptional],
  238. HH: ["hour", twoDigits],
  239. m: ["minute", twoDigitsOptional],
  240. mm: ["minute", twoDigits],
  241. s: ["second", twoDigitsOptional],
  242. ss: ["second", twoDigits],
  243. YYYY: ["year", fourDigits],
  244. S: ["millisecond", "\\d", (v: string): number => +v * 100],
  245. SS: ["millisecond", twoDigits, (v: string): number => +v * 10],
  246. SSS: ["millisecond", threeDigits],
  247. d: emptyDigits,
  248. dd: emptyDigits,
  249. ddd: emptyWord,
  250. dddd: emptyWord,
  251. MMM: ["month", word, monthUpdate("monthNamesShort")],
  252. MMMM: ["month", word, monthUpdate("monthNames")],
  253. a: amPm,
  254. A: amPm,
  255. ZZ: timezoneOffset,
  256. Z: timezoneOffset
  257. };
  258. // Some common format strings
  259. const globalMasks: { [key: string]: string } = {
  260. default: "ddd MMM DD YYYY HH:mm:ss",
  261. shortDate: "M/D/YY",
  262. mediumDate: "MMM D, YYYY",
  263. longDate: "MMMM D, YYYY",
  264. fullDate: "dddd, MMMM D, YYYY",
  265. isoDate: "YYYY-MM-DD",
  266. isoDateTime: "YYYY-MM-DDTHH:mm:ssZ",
  267. shortTime: "HH:mm",
  268. mediumTime: "HH:mm:ss",
  269. longTime: "HH:mm:ss.SSS"
  270. };
  271. const setGlobalDateMasks = (masks: {
  272. [key: string]: string;
  273. }): { [key: string]: string } => assign(globalMasks, masks);
  274. /***
  275. * Format a date
  276. * @method format
  277. * @param {Date|number} dateObj
  278. * @param {string} mask Format of the date, i.e. 'mm-dd-yy' or 'shortDate'
  279. * @returns {string} Formatted date string
  280. */
  281. const format = (
  282. dateObj: Date,
  283. mask: string = globalMasks["default"],
  284. i18n: I18nSettingsOptional = {}
  285. ): string => {
  286. if (typeof dateObj === "number") {
  287. dateObj = new Date(dateObj);
  288. }
  289. if (
  290. Object.prototype.toString.call(dateObj) !== "[object Date]" ||
  291. isNaN(dateObj.getTime())
  292. ) {
  293. throw new Error("Invalid Date pass to format");
  294. }
  295. mask = globalMasks[mask] || mask;
  296. const literals: string[] = [];
  297. // Make literals inactive by replacing them with @@@
  298. mask = mask.replace(literal, function($0, $1) {
  299. literals.push($1);
  300. return "@@@";
  301. });
  302. const combinedI18nSettings: I18nSettings = assign(
  303. assign({}, globalI18n),
  304. i18n
  305. );
  306. // Apply formatting rules
  307. mask = mask.replace(token, $0 =>
  308. formatFlags[$0](dateObj, combinedI18nSettings)
  309. );
  310. // Inline literal values back into the formatted value
  311. return mask.replace(/@@@/g, () => literals.shift());
  312. };
  313. /**
  314. * Parse a date string into a Javascript Date object /
  315. * @method parse
  316. * @param {string} dateStr Date string
  317. * @param {string} format Date parse format
  318. * @param {i18n} I18nSettingsOptional Full or subset of I18N settings
  319. * @returns {Date|null} Returns Date object. Returns null what date string is invalid or doesn't match format
  320. */
  321. function parse(
  322. dateStr: string,
  323. format: string,
  324. i18n: I18nSettingsOptional = {}
  325. ): Date | null {
  326. if (typeof format !== "string") {
  327. throw new Error("Invalid format in fecha parse");
  328. }
  329. // Check to see if the format is actually a mask
  330. format = globalMasks[format] || format;
  331. // Avoid regular expression denial of service, fail early for really long strings
  332. // https://www.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS
  333. if (dateStr.length > 1000) {
  334. return null;
  335. }
  336. // Default to the beginning of the year.
  337. const today = new Date();
  338. const dateInfo: DateInfo = {
  339. year: today.getFullYear(),
  340. month: 0,
  341. day: 1,
  342. hour: 0,
  343. minute: 0,
  344. second: 0,
  345. millisecond: 0,
  346. isPm: null,
  347. timezoneOffset: null
  348. };
  349. const parseInfo: ParseInfo[] = [];
  350. const literals: string[] = [];
  351. // Replace all the literals with @@@. Hopefully a string that won't exist in the format
  352. let newFormat = format.replace(literal, ($0, $1) => {
  353. literals.push(regexEscape($1));
  354. return "@@@";
  355. });
  356. const specifiedFields: { [field: string]: boolean } = {};
  357. const requiredFields: { [field: string]: boolean } = {};
  358. // Change every token that we find into the correct regex
  359. newFormat = regexEscape(newFormat).replace(token, $0 => {
  360. const info = parseFlags[$0];
  361. const [field, regex, , requiredField] = info;
  362. // Check if the person has specified the same field twice. This will lead to confusing results.
  363. if (specifiedFields[field]) {
  364. throw new Error(`Invalid format. ${field} specified twice in format`);
  365. }
  366. specifiedFields[field] = true;
  367. // Check if there are any required fields. For instance, 12 hour time requires AM/PM specified
  368. if (requiredField) {
  369. requiredFields[requiredField] = true;
  370. }
  371. parseInfo.push(info);
  372. return "(" + regex + ")";
  373. });
  374. // Check all the required fields are present
  375. Object.keys(requiredFields).forEach(field => {
  376. if (!specifiedFields[field]) {
  377. throw new Error(
  378. `Invalid format. ${field} is required in specified format`
  379. );
  380. }
  381. });
  382. // Add back all the literals after
  383. newFormat = newFormat.replace(/@@@/g, () => literals.shift());
  384. // Check if the date string matches the format. If it doesn't return null
  385. const matches = dateStr.match(new RegExp(newFormat, "i"));
  386. if (!matches) {
  387. return null;
  388. }
  389. const combinedI18nSettings: I18nSettings = assign(
  390. assign({}, globalI18n),
  391. i18n
  392. );
  393. // For each match, call the parser function for that date part
  394. for (let i = 1; i < matches.length; i++) {
  395. const [field, , parser] = parseInfo[i - 1];
  396. const value = parser
  397. ? parser(matches[i], combinedI18nSettings)
  398. : +matches[i];
  399. // If the parser can't make sense of the value, return null
  400. if (value == null) {
  401. return null;
  402. }
  403. dateInfo[field] = value;
  404. }
  405. if (dateInfo.isPm === 1 && dateInfo.hour != null && +dateInfo.hour !== 12) {
  406. dateInfo.hour = +dateInfo.hour + 12;
  407. } else if (dateInfo.isPm === 0 && +dateInfo.hour === 12) {
  408. dateInfo.hour = 0;
  409. }
  410. let dateTZ: Date;
  411. if (dateInfo.timezoneOffset == null) {
  412. dateTZ = new Date(
  413. dateInfo.year,
  414. dateInfo.month,
  415. dateInfo.day,
  416. dateInfo.hour,
  417. dateInfo.minute,
  418. dateInfo.second,
  419. dateInfo.millisecond
  420. );
  421. const validateFields: [
  422. "month" | "day" | "hour" | "minute" | "second",
  423. "getMonth" | "getDate" | "getHours" | "getMinutes" | "getSeconds"
  424. ][] = [
  425. ["month", "getMonth"],
  426. ["day", "getDate"],
  427. ["hour", "getHours"],
  428. ["minute", "getMinutes"],
  429. ["second", "getSeconds"]
  430. ];
  431. for (let i = 0, len = validateFields.length; i < len; i++) {
  432. // Check to make sure the date field is within the allowed range. Javascript dates allows values
  433. // outside the allowed range. If the values don't match the value was invalid
  434. if (
  435. specifiedFields[validateFields[i][0]] &&
  436. dateInfo[validateFields[i][0]] !== dateTZ[validateFields[i][1]]()
  437. ) {
  438. return null;
  439. }
  440. }
  441. } else {
  442. dateTZ = new Date(
  443. Date.UTC(
  444. dateInfo.year,
  445. dateInfo.month,
  446. dateInfo.day,
  447. dateInfo.hour,
  448. dateInfo.minute - dateInfo.timezoneOffset,
  449. dateInfo.second,
  450. dateInfo.millisecond
  451. )
  452. );
  453. // We can't validate dates in another timezone unfortunately. Do a basic check instead
  454. if (
  455. dateInfo.month > 11 ||
  456. dateInfo.month < 0 ||
  457. dateInfo.day > 31 ||
  458. dateInfo.day < 1 ||
  459. dateInfo.hour > 23 ||
  460. dateInfo.hour < 0 ||
  461. dateInfo.minute > 59 ||
  462. dateInfo.minute < 0 ||
  463. dateInfo.second > 59 ||
  464. dateInfo.second < 0
  465. ) {
  466. return null;
  467. }
  468. }
  469. // Don't allow invalid dates
  470. return dateTZ;
  471. }
  472. export default {
  473. format,
  474. parse,
  475. defaultI18n,
  476. setGlobalDateI18n,
  477. setGlobalDateMasks
  478. };
  479. export { format, parse, defaultI18n, setGlobalDateI18n, setGlobalDateMasks };