diff --git a/README.md b/README.md index c991f9cbd..98458ef02 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Validator | Description **isCreditCard(str [, options])** | check if the string is a credit card number.

`options` is an optional object that can be supplied with the following key(s): `provider` is an optional key whose value should be a string, and defines the company issuing the credit card. Valid values include `['amex', 'dinersclub', 'discover', 'jcb', 'mastercard', 'unionpay', 'visa']` or blank will check for any provider. **isCurrency(str [, options])** | check if the string is a valid currency amount.

`options` is an object which defaults to `{ symbol: '$', require_symbol: false, allow_space_after_symbol: false, symbol_after_digits: false, allow_negatives: true, parens_for_negatives: false, negative_sign_before_digits: false, negative_sign_after_digits: false, allow_negative_sign_placeholder: false, thousands_separator: ',', decimal_separator: '.', allow_decimal: true, require_decimal: false, digits_after_decimal: [2], allow_space_after_digits: false }`.
**Note:** The array `digits_after_decimal` is filled with the exact number of digits allowed not a range, for example a range 1 to 3 will be given as [1, 2, 3]. **isDataURI(str)** | check if the string is a [data uri format][Data URI Format]. -**isDate(str [, options])** | check if the string is a valid date. e.g. [`2002-07-15`, new Date()].

`options` is an object which can contain the keys `format`, `strictMode` and/or `delimiters`.

`format` is a string and defaults to `YYYY/MM/DD`.

`strictMode` is a boolean and defaults to `false`. If `strictMode` is set to true, the validator will reject strings different from `format`.

`delimiters` is an array of allowed date delimiters and defaults to `['/', '-']`. +**isDate(str [, options])** | check if the string is a valid date. e.g. [`2002-07-15`, `20020715`, new Date()].

`options` is an object which can contain the keys `format`, `strictMode`, `delimiters` and/or `allowDelimiterless`.

`format` is a string and defaults to `YYYY/MM/DD`. Supports delimiter-based formats (e.g., `YYYY/MM/DD`, `DD-MM-YYYY`) and ISO 8601 compliant delimiter-less formats (e.g., `YYYYMMDD`, `YYMMDD`) when `allowDelimiterless` is enabled.

`strictMode` is a boolean and defaults to `false`. If `strictMode` is set to true, the validator will reject strings different from `format`.

`delimiters` is an array of allowed date delimiters and defaults to `['/', '-']`. This option is ignored for delimiter-less formats.

`allowDelimiterless` is a boolean and defaults to `false`. Set to `true` to enable ISO 8601 compliant delimiter-less format validation (e.g., `YYYYMMDD`). **isDecimal(str [, options])** | check if the string represents a decimal number, such as 0.1, .3, 1.1, 1.00003, 4.0, etc.

`options` is an object which defaults to `{force_decimal: false, decimal_digits: '1,', locale: 'en-US'}`.

`locale` determines the decimal separator and is one of `['ar', 'ar-AE', 'ar-BH', 'ar-DZ', 'ar-EG', 'ar-IQ', 'ar-JO', 'ar-KW', 'ar-LB', 'ar-LY', 'ar-MA', 'ar-QA', 'ar-QM', 'ar-SA', 'ar-SD', 'ar-SY', 'ar-TN', 'ar-YE', 'bg-BG', 'cs-CZ', 'da-DK', 'de-DE', 'el-GR', 'en-AU', 'en-GB', 'en-HK', 'en-IN', 'en-NZ', 'en-US', 'en-ZA', 'en-ZM', 'eo', 'es-ES', 'fa', 'fa-AF', 'fa-IR', 'fr-FR', 'fr-CA', 'hu-HU', 'id-ID', 'it-IT', 'ku-IQ', 'nb-NO', 'nl-NL', 'nn-NO', 'pl-PL', 'pl-Pl', 'pt-BR', 'pt-PT', 'ru-RU', 'sl-SI', 'sr-RS', 'sr-RS@latin', 'sv-SE', 'tr-TR', 'uk-UA', 'vi-VN']`.
**Note:** `decimal_digits` is given as a range like '1,3', a specific value like '3' or min like '1,'. **isDivisibleBy(str, number)** | check if the string is a number that is divisible by another. **isEAN(str)** | check if the string is an [EAN (European Article Number)][European Article Number]. diff --git a/src/lib/isDate.js b/src/lib/isDate.js index 3a1e4afd2..bcaba178f 100644 --- a/src/lib/isDate.js +++ b/src/lib/isDate.js @@ -4,10 +4,24 @@ const default_date_options = { format: 'YYYY/MM/DD', delimiters: ['/', '-'], strictMode: false, + allowDelimiterless: false, }; -function isValidFormat(format) { - return /(^(y{4}|y{2})[.\/-](m{1,2})[.\/-](d{1,2})$)|(^(m{1,2})[.\/-](d{1,2})[.\/-]((y{4}|y{2})$))|(^(d{1,2})[.\/-](m{1,2})[.\/-]((y{4}|y{2})$))/gi.test(format); +function isValidFormat(format, allowDelimiterless) { + // Formats with delimiters + const withDelimiters = /(^(y{4}|y{2})[\.\/-](m{1,2})[\.\/-](d{1,2})$)|(^(m{1,2})[\.\/-](d{1,2})[\.\/-]((y{4}|y{2})$))|(^(d{1,2})[\.\/-](m{1,2})[\.\/-]((y{4}|y{2})$))/gi; + + if (withDelimiters.test(format)) { + return true; + } + + // Formats without delimiters (e.g., YYYYMMDD, YYMMDD) + if (allowDelimiterless) { + const withoutDelimiters = /^(y{4}|y{2})(m{2})(d{2})$/gi; + return withoutDelimiters.test(format); + } + + return false; } function zip(date, format) { @@ -21,14 +35,85 @@ function zip(date, format) { return zippedArr; } +function hasNoDelimiter(format, delimiters) { + return !delimiters.some(delimiter => format.indexOf(delimiter) !== -1); +} + +function parseDelimiterlessFormat(format) { + // Extract component positions from format like + // YYYYMMDD, YYMMDD (ISO 8601 compliant) + const lowerFormat = format.toLowerCase(); + const components = []; + let pos = 0; + + while (pos < lowerFormat.length) { + const char = lowerFormat[pos]; + let len = 0; + + while (pos + len < lowerFormat.length && lowerFormat[pos + len] === char) { + len += 1; + } + + components.push({ + type: char, + start: pos, + length: len, + }); + + pos += len; + } + + return components; +} + +function extractDatePartsWithoutDelimiter(input, format) { + const components = parseDelimiterlessFormat(format); + const dateObj = {}; + + for (const comp of components) { + dateObj[comp.type] = input.substring( + comp.start, + comp.start + comp.length + ); + } + + return dateObj; +} + export default function isDate(input, options) { - if (typeof options === 'string') { // Allow backward compatibility for old format isDate(input [, format]) + // Allow backward compatibility for old format isDate(input [, format]) + if (typeof options === 'string') { options = merge({ format: options }, default_date_options); } else { options = merge(options, default_date_options); } - if (typeof input === 'string' && isValidFormat(options.format)) { - if (options.strictMode && input.length !== options.format.length) return false; + + // Handle non-string input + if (typeof input !== 'string' || !isValidFormat(options.format, options.allowDelimiterless)) { + if (!options.strictMode) { + return ( + Object.prototype.toString.call(input) === '[object Date]' && + isFinite(input) + ); + } + return false; + } + + if (options.strictMode && input.length !== options.format.length) { + return false; + } + + let dateObj; + + // Check if format has no delimiters (e.g., YYYYMMDD) + if (hasNoDelimiter(options.format, options.delimiters)) { + // Delimiter-less format parsing + if (input.length !== options.format.length) { + return false; + } + dateObj = extractDatePartsWithoutDelimiter(input, options.format); + } else { + // Original delimiter-based parsing const formatDelimiter = options.delimiters .find(delimiter => options.format.indexOf(delimiter) !== -1); const dateDelimiter = options.strictMode @@ -38,7 +123,7 @@ export default function isDate(input, options) { input.split(dateDelimiter), options.format.toLowerCase().split(formatDelimiter) ); - const dateObj = {}; + dateObj = {}; for (const [dateWord, formatWord] of dateAndFormat) { if (!dateWord || !formatWord || dateWord.length !== formatWord.length) { @@ -47,48 +132,34 @@ export default function isDate(input, options) { dateObj[formatWord.charAt(0)] = dateWord; } + } - let fullYear = dateObj.y; - - // Check if the year starts with a hyphen - if (fullYear.startsWith('-')) { - return false; // Hyphen before year is not allowed - } - - if (dateObj.y.length === 2) { - const parsedYear = parseInt(dateObj.y, 10); - - if (isNaN(parsedYear)) { - return false; - } - - const currentYearLastTwoDigits = new Date().getFullYear() % 100; + let fullYear = dateObj.y; - if (parsedYear < currentYearLastTwoDigits) { - fullYear = `20${dateObj.y}`; - } else { - fullYear = `19${dateObj.y}`; - } - } + // Check if the year starts with a hyphen + if (fullYear.startsWith('-')) { + return false; // Hyphen before year is not allowed + } - let month = dateObj.m; + if (dateObj.y.length === 2) { + const parsedYear = parseInt(dateObj.y, 10); - if (dateObj.m.length === 1) { - month = `0${dateObj.m}`; + if (isNaN(parsedYear)) { + return false; } - let day = dateObj.d; + const currentYearLastTwoDigits = new Date().getFullYear() % 100; - if (dateObj.d.length === 1) { - day = `0${dateObj.d}`; + if (parsedYear < currentYearLastTwoDigits) { + fullYear = `20${dateObj.y}`; + } else { + fullYear = `19${dateObj.y}`; } - - return new Date(`${fullYear}-${month}-${day}T00:00:00.000Z`).getUTCDate() === +dateObj.d; } - if (!options.strictMode) { - return Object.prototype.toString.call(input) === '[object Date]' && isFinite(input); - } + const month = dateObj.m.length === 1 ? `0${dateObj.m}` : dateObj.m; + const day = dateObj.d.length === 1 ? `0${dateObj.d}` : dateObj.d; - return false; + const dateString = `${fullYear}-${month}-${day}T00:00:00.000Z`; + return new Date(dateString).getUTCDate() === +dateObj.d; } diff --git a/test/validators.test.js b/test/validators.test.js index 9bd00d6ec..f07ce2e22 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -14467,6 +14467,64 @@ describe('Validators', () => { 'abc.04.05.2024', ], }); + // Test delimiter-less format YYYYMMDD + test({ + validator: 'isDate', + args: [{ format: 'YYYYMMDD', allowDelimiterless: true }], + valid: [ + '20200229', // leap year + '20200115', + '20140215', + '20140315', + '19991231', + '20000101', + ], + invalid: [ + '2020-02-29', + '2020/02/29', + '20200230', // invalid date + '20190229', // non-leap year + '20200431', // invalid date + '2020229', // too short + '202002290', // too long + '', + 'abcdefgh', + '2020ab29', + '-20200229', + ], + }); + // Test delimiter-less format YYMMDD (two-digit year) + test({ + validator: 'isDate', + args: [{ format: 'YYMMDD', allowDelimiterless: true }], + valid: [ + '200229', // 2020 leap year + '140215', + '991231', + '000101', + ], + invalid: [ + '20200229', + '2020-02-29', + '20229', // too short + '2002290', // too long + '', + 'abcdef', + '20ab29', + '-200229', + ], + }); + // Test that delimiter-less formats are rejected without allowDelimiterless option + test({ + validator: 'isDate', + args: [{ format: 'YYYYMMDD' }], + valid: [], + invalid: [ + '20200229', + '20020715', + '19991231', + ], + }); // emulating Pacific time zone offset & time // which could potentially result in UTC conversion issues timezone_mock.register('US/Pacific');