From 989beaf5f73821503a6276d7d469dc55d8b0e07c Mon Sep 17 00:00:00 2001 From: Juan Segnana <37079836+juansegnana@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:05:30 -0300 Subject: [PATCH 1/2] feat(es): Enhance Spanish locale with new parsers and configurations - Added new parsers for casual date formats, including month and year combinations. - Introduced refiners for merging relative dates with absolute dates. - Updated constants to include full month names and ordinal numbers. - Created a default configuration class for Spanish chrono, streamlining parser integration. - Added comprehensive tests for relative date expressions and time units in Spanish. This update improves the accuracy and flexibility of date parsing in the Spanish locale. --- package-lock.json | 9 - src/locales/es/configuration.ts | 78 ++++++ src/locales/es/constants.ts | 223 ++++++++++++++---- src/locales/es/index.ts | 59 ++--- .../es/parsers/ESCasualYearMonthDayParser.ts | 49 ++++ .../parsers/ESMonthNameMiddleEndianParser.ts | 92 ++++++++ src/locales/es/parsers/ESMonthNameParser.ts | 63 +++++ .../es/parsers/ESRelativeDateFormatParser.ts | 91 +++++++ .../es/parsers/ESSlashMonthFormatParser.ts | 26 ++ .../es/parsers/ESTimeUnitAgoFormatParser.ts | 27 +++ .../ESTimeUnitCasualRelativeFormatParser.ts | 45 ++++ .../es/parsers/ESTimeUnitLaterFormatParser.ts | 28 +++ .../es/refiners/ESMergeRelativeDateRefiner.ts | 58 +++++ test/es/es_relative.test.ts | 91 +++++++ test/es/es_time_units_ago.test.ts | 174 ++++++++++++++ test/es/es_time_units_later.test.ts | 89 +++++++ test/es/es_year_month_day.test.ts | 37 +++ 17 files changed, 1147 insertions(+), 92 deletions(-) create mode 100644 src/locales/es/configuration.ts create mode 100644 src/locales/es/parsers/ESCasualYearMonthDayParser.ts create mode 100644 src/locales/es/parsers/ESMonthNameMiddleEndianParser.ts create mode 100644 src/locales/es/parsers/ESMonthNameParser.ts create mode 100644 src/locales/es/parsers/ESRelativeDateFormatParser.ts create mode 100644 src/locales/es/parsers/ESSlashMonthFormatParser.ts create mode 100644 src/locales/es/parsers/ESTimeUnitAgoFormatParser.ts create mode 100644 src/locales/es/parsers/ESTimeUnitCasualRelativeFormatParser.ts create mode 100644 src/locales/es/parsers/ESTimeUnitLaterFormatParser.ts create mode 100644 src/locales/es/refiners/ESMergeRelativeDateRefiner.ts create mode 100644 test/es/es_relative.test.ts create mode 100644 test/es/es_time_units_ago.test.ts create mode 100644 test/es/es_time_units_later.test.ts create mode 100644 test/es/es_year_month_day.test.ts diff --git a/package-lock.json b/package-lock.json index 0929913f..61aff420 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "chrono-node", "version": "2.9.0", "license": "MIT", - "dependencies": { - "dayjs": "^1.10.0" - }, "devDependencies": { "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^8.32.1", @@ -2563,12 +2560,6 @@ "node": ">= 8" } }, - "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/src/locales/es/configuration.ts b/src/locales/es/configuration.ts new file mode 100644 index 00000000..d3ccdd63 --- /dev/null +++ b/src/locales/es/configuration.ts @@ -0,0 +1,78 @@ +import { Configuration } from "../../chrono"; + +import ESTimeUnitWithinFormatParser from "./parsers/ESTimeUnitWithinFormatParser"; +import ESMonthNameLittleEndianParser from "./parsers/ESMonthNameLittleEndianParser"; +import ESMonthNameMiddleEndianParser from "./parsers/ESMonthNameMiddleEndianParser"; +import ESMonthNameParser from "./parsers/ESMonthNameParser"; +import ESCasualYearMonthDayParser from "./parsers/ESCasualYearMonthDayParser"; +import ESSlashMonthFormatParser from "./parsers/ESSlashMonthFormatParser"; +import ESTimeExpressionParser from "./parsers/ESTimeExpressionParser"; +import ESTimeUnitAgoFormatParser from "./parsers/ESTimeUnitAgoFormatParser"; +import ESTimeUnitLaterFormatParser from "./parsers/ESTimeUnitLaterFormatParser"; +import ESMergeDateRangeRefiner from "./refiners/ESMergeDateRangeRefiner"; +import ESMergeDateTimeRefiner from "./refiners/ESMergeDateTimeRefiner"; + +import { includeCommonConfiguration } from "../../configurations"; +import ESCasualDateParser from "./parsers/ESCasualDateParser"; +import ESCasualTimeParser from "./parsers/ESCasualTimeParser"; +import ESWeekdayParser from "./parsers/ESWeekdayParser"; +import ESRelativeDateFormatParser from "./parsers/ESRelativeDateFormatParser"; + +import SlashDateFormatParser from "../../common/parsers/SlashDateFormatParser"; +import ESTimeUnitCasualRelativeFormatParser from "./parsers/ESTimeUnitCasualRelativeFormatParser"; +import ESMergeRelativeDateRefiner from "./refiners/ESMergeRelativeDateRefiner"; +import OverlapRemovalRefiner from "../../common/refiners/OverlapRemovalRefiner"; + +export default class ESDefaultConfiguration { + /** + * Create a default *casual* {@Link Configuration} for Spanish chrono. + * It calls {@Link createConfiguration} and includes additional parsers. + */ + createCasualConfiguration(littleEndian = true): Configuration { + const option = this.createConfiguration(false, littleEndian); + option.parsers.push(new ESCasualDateParser()); + option.parsers.push(new ESCasualTimeParser()); + option.parsers.push(new ESMonthNameParser()); + option.parsers.push(new ESRelativeDateFormatParser()); + option.parsers.push(new ESTimeUnitCasualRelativeFormatParser()); + return option; + } + + /** + * Create a default {@Link Configuration} for Spanish chrono + * + * @param strictMode If the timeunit mentioning should be strict, not casual + * @param littleEndian If format should be date-first/littleEndian (e.g. es_ES), not month-first/middleEndian (e.g. en_US) + */ + createConfiguration(strictMode = true, littleEndian = true): Configuration { + const options = includeCommonConfiguration( + { + parsers: [ + new SlashDateFormatParser(littleEndian), + new ESTimeUnitWithinFormatParser(), + new ESMonthNameLittleEndianParser(), + new ESMonthNameMiddleEndianParser(/*shouldSkipYearLikeDate=*/ littleEndian), + new ESWeekdayParser(), + new ESCasualYearMonthDayParser(), + new ESSlashMonthFormatParser(), + new ESTimeExpressionParser(strictMode), + new ESTimeUnitAgoFormatParser(strictMode), + new ESTimeUnitLaterFormatParser(strictMode), + ], + refiners: [new ESMergeDateTimeRefiner()], + }, + strictMode + ); + + // These relative-dates consideration should be done before other common refiners. + options.refiners.unshift(new ESMergeRelativeDateRefiner()); + options.refiners.unshift(new OverlapRemovalRefiner()); + + // Re-apply the date time refiner again after the timezone refinement and exclusion in common refiners. + options.refiners.push(new ESMergeDateTimeRefiner()); + + // Keep the date range refiner at the end (after all other refinements). + options.refiners.push(new ESMergeDateRangeRefiner()); + return options; + } +} diff --git a/src/locales/es/constants.ts b/src/locales/es/constants.ts index 10369912..b0c94fea 100644 --- a/src/locales/es/constants.ts +++ b/src/locales/es/constants.ts @@ -1,4 +1,6 @@ import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern"; +import { findMostLikelyADYear } from "../../calculation/years"; +import { Duration } from "../../calculation/duration"; import { Timeunit } from "../../types"; export const WEEKDAY_DICTIONARY: { [word: string]: number } = { @@ -22,42 +24,48 @@ export const WEEKDAY_DICTIONARY: { [word: string]: number } = { "sab": 6, }; -export const MONTH_DICTIONARY: { [word: string]: number } = { +export const FULL_MONTH_NAME_DICTIONARY: { [word: string]: number } = { "enero": 1, + "febrero": 2, + "marzo": 3, + "abril": 4, + "mayo": 5, + "junio": 6, + "julio": 7, + "agosto": 8, + "septiembre": 9, + "setiembre": 9, + "octubre": 10, + "noviembre": 11, + "diciembre": 12, +}; + +export const MONTH_DICTIONARY: { [word: string]: number } = { + ...FULL_MONTH_NAME_DICTIONARY, "ene": 1, "ene.": 1, - "febrero": 2, "feb": 2, "feb.": 2, - "marzo": 3, "mar": 3, "mar.": 3, - "abril": 4, "abr": 4, "abr.": 4, - "mayo": 5, "may": 5, "may.": 5, - "junio": 6, "jun": 6, "jun.": 6, - "julio": 7, "jul": 7, "jul.": 7, - "agosto": 8, "ago": 8, "ago.": 8, - "septiembre": 9, - "setiembre": 9, "sep": 9, "sep.": 9, - "octubre": 10, + "sept": 9, + "sept.": 9, "oct": 10, "oct.": 10, - "noviembre": 11, "nov": 11, "nov.": 11, - "diciembre": 12, "dic": 12, "dic.": 12, }; @@ -78,10 +86,96 @@ export const INTEGER_WORD_DICTIONARY: { [word: string]: number } = { "trece": 13, }; +export const ORDINAL_WORD_DICTIONARY: { [word: string]: number } = { + "primero": 1, + "primera": 1, + "segundo": 2, + "segunda": 2, + "tercero": 3, + "tercera": 3, + "cuarto": 4, + "cuarta": 4, + "quinto": 5, + "quinta": 5, + "sexto": 6, + "sexta": 6, + "séptimo": 7, + "septimo": 7, + "séptima": 7, + "septima": 7, + "octavo": 8, + "octava": 8, + "noveno": 9, + "novena": 9, + "décimo": 10, + "decimo": 10, + "décima": 10, + "decima": 10, + "undécimo": 11, + "undecimo": 11, + "duodécimo": 12, + "duodecimo": 12, + "decimotercero": 13, + "decimocuarto": 14, + "decimoquinto": 15, + "decimosexto": 16, + "decimoséptimo": 17, + "decimoseptimo": 17, + "decimoctavo": 18, + "decimonoveno": 19, + "vigésimo": 20, + "vigesimo": 20, + "vigésimo primero": 21, + "vigesimo primero": 21, + "vigésimo segundo": 22, + "vigesimo segundo": 22, + "vigésimo tercero": 23, + "vigesimo tercero": 23, + "vigésimo cuarto": 24, + "vigesimo cuarto": 24, + "vigésimo quinto": 25, + "vigesimo quinto": 25, + "vigésimo sexto": 26, + "vigesimo sexto": 26, + "vigésimo séptimo": 27, + "vigesimo septimo": 27, + "vigésimo octavo": 28, + "vigesimo octavo": 28, + "vigésimo noveno": 29, + "vigesimo noveno": 29, + "trigésimo": 30, + "trigesimo": 30, + "trigésimo primero": 31, + "trigesimo primero": 31, +}; + +export const TIME_UNIT_DICTIONARY_NO_ABBR: { [word: string]: Timeunit } = { + "segundo": "second", + "segundos": "second", + "minuto": "minute", + "minutos": "minute", + "hora": "hour", + "horas": "hour", + "día": "day", + "dias": "day", + "días": "day", + "semana": "week", + "semanas": "week", + "mes": "month", + "meses": "month", + "trimestre": "quarter", + "trimestres": "quarter", + "año": "year", + "años": "year", +}; + export const TIME_UNIT_DICTIONARY: { [word: string]: Timeunit } = { + "s": "second", + "seg": "second", "sec": "second", "segundo": "second", "segundos": "second", + "m": "minute", "min": "minute", "mins": "minute", "minuto": "minute", @@ -91,84 +185,129 @@ export const TIME_UNIT_DICTIONARY: { [word: string]: Timeunit } = { "hrs": "hour", "hora": "hour", "horas": "hour", + "d": "day", "día": "day", + "dias": "day", "días": "day", + "sem": "week", "semana": "week", "semanas": "week", "mes": "month", "meses": "month", - "cuarto": "quarter", - "cuartos": "quarter", + "trim": "quarter", + "trimestre": "quarter", + "trimestres": "quarter", + "a": "year", "año": "year", "años": "year", + ...TIME_UNIT_DICTIONARY_NO_ABBR, }; //----------------------------- export const NUMBER_PATTERN = `(?:${matchAnyPattern( INTEGER_WORD_DICTIONARY -)}|[0-9]+|[0-9]+\\.[0-9]+|un?|uno?|una?|algunos?|unos?|demi-?)`; +)}|[0-9]+|[0-9]+\\.[0-9]+|media(?:\\s{0,2}una?)?|un?a?\\b(?:\\s{0,2}pocos?)?|pocos?|algunos?|varios?|el|la|un?\\s{0,2}par(?:\\s{0,2}de)?)`; export function parseNumberPattern(match: string): number { const num = match.toLowerCase(); if (INTEGER_WORD_DICTIONARY[num] !== undefined) { return INTEGER_WORD_DICTIONARY[num]; - } else if (num === "un" || num === "una" || num === "uno") { + } else if (num === "un" || num === "una" || num === "uno" || num === "el" || num === "la") { return 1; - } else if (num.match(/algunos?/)) { + } else if (num.match(/pocos?/)) { return 3; - } else if (num.match(/unos?/)) { + } else if (num.match(/algunos?/)) { return 3; - } else if (num.match(/media?/)) { + } else if (num.match(/media/)) { return 0.5; + } else if (num.match(/par/)) { + return 2; + } else if (num.match(/varios?/)) { + return 7; } return parseFloat(num); } + //----------------------------- -// 88 p. Chr. n. -// 234 AC -export const YEAR_PATTERN = "[0-9]{1,4}(?![^\\s]\\d)(?:\\s*[a|d]\\.?\\s*c\\.?|\\s*a\\.?\\s*d\\.?)?"; -export function parseYear(match: string): number { - if (match.match(/^[0-9]{1,4}$/)) { - let yearNumber = parseInt(match); - if (yearNumber < 100) { - if (yearNumber > 50) { - yearNumber = yearNumber + 1900; - } else { - yearNumber = yearNumber + 2000; - } - } - return yearNumber; + +export const ORDINAL_NUMBER_PATTERN = `(?:${matchAnyPattern( + ORDINAL_WORD_DICTIONARY +)}|[0-9]{1,2}(?:ro|do|to|mo|er|vo|no|ma|era|ero|avo|ava)?)`; + +export function parseOrdinalNumberPattern(match: string): number { + let num = match.toLowerCase(); + if (ORDINAL_WORD_DICTIONARY[num] !== undefined) { + return ORDINAL_WORD_DICTIONARY[num]; } - if (match.match(/a\.?\s*c\.?/i)) { - match = match.replace(/a\.?\s*c\.?/i, ""); + num = num.replace(/(?:ro|do|to|mo|er|vo|no|ma|era|ero|avo|ava)$/i, ""); + return parseInt(num); +} + +//----------------------------- + +export const YEAR_PATTERN = `(?:[1-9][0-9]{0,3}\\s{0,2}(?:AC|DC|a\\.?\\s*c\\.?|d\\.?\\s*c\\.?)|[1-2][0-9]{3}|[5-9][0-9])`; +export function parseYear(match: string): number { + if (/AC|a\.?\s*c\.?/i.test(match)) { + // Antes de Cristo + match = match.replace(/AC|a\.?\s*c\.?/i, ""); return -parseInt(match); } - return parseInt(match); + if (/DC|d\.?\s*c\.?/i.test(match)) { + // Después de Cristo + match = match.replace(/DC|d\.?\s*c\.?/i, ""); + return parseInt(match); + } + + const rawYearNumber = parseInt(match); + return findMostLikelyADYear(rawYearNumber); } -const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`; +//----------------------------- + +const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,3}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})`; const SINGLE_TIME_UNIT_REGEX = new RegExp(SINGLE_TIME_UNIT_PATTERN, "i"); -export const TIME_UNITS_PATTERN = repeatedTimeunitPattern("", SINGLE_TIME_UNIT_PATTERN); +const SINGLE_TIME_UNIT_NO_ABBR_PATTERN = `(${NUMBER_PATTERN})\\s{0,3}(${matchAnyPattern( + TIME_UNIT_DICTIONARY_NO_ABBR +)})`; -import { Duration } from "../../calculation/duration"; -export function parseDuration(timeunitText): Duration { +const TIME_UNIT_CONNECTOR_PATTERN = `\\s{0,5},?(?:\\s*y)?\\s{0,5}`; + +export const TIME_UNITS_PATTERN = repeatedTimeunitPattern( + `(?:(?:aproximadamente|alrededor de|cerca de)\\s{0,3})?`, + SINGLE_TIME_UNIT_PATTERN, + TIME_UNIT_CONNECTOR_PATTERN +); + +export const TIME_UNITS_NO_ABBR_PATTERN = repeatedTimeunitPattern( + `(?:(?:aproximadamente|alrededor de|cerca de)\\s{0,3})?`, + SINGLE_TIME_UNIT_NO_ABBR_PATTERN, + TIME_UNIT_CONNECTOR_PATTERN +); + +export function parseDuration(timeunitText): null | Duration { const fragments = {}; let remainingText = timeunitText; let match = SINGLE_TIME_UNIT_REGEX.exec(remainingText); while (match) { collectDateTimeFragment(fragments, match); - remainingText = remainingText.substring(match[0].length); + remainingText = remainingText.substring(match[0].length).trim(); match = SINGLE_TIME_UNIT_REGEX.exec(remainingText); } + if (Object.keys(fragments).length == 0) { + return null; + } return fragments as Duration; } function collectDateTimeFragment(fragments, match) { + if (match[0].match(/^[a-zA-ZáéíóúñÁÉÍÓÚÑ]+$/)) { + return; + } const num = parseNumberPattern(match[1]); const unit = TIME_UNIT_DICTIONARY[match[2].toLowerCase()]; fragments[unit] = num; diff --git a/src/locales/es/index.ts b/src/locales/es/index.ts index 24d5d078..4faa2ad6 100644 --- a/src/locales/es/index.ts +++ b/src/locales/es/index.ts @@ -4,60 +4,37 @@ * @module */ -import { includeCommonConfiguration } from "../../configurations"; -import { Chrono, Configuration, Parser, Refiner } from "../../chrono"; +import { Chrono, Parser, Refiner } from "../../chrono"; import { ParsingResult, ParsingComponents, ReferenceWithTimezone } from "../../results"; import { Component, ParsedResult, ParsingOption, ParsingReference, Meridiem, Weekday } from "../../types"; -import SlashDateFormatParser from "../../common/parsers/SlashDateFormatParser"; -import ESWeekdayParser from "./parsers/ESWeekdayParser"; -import ESTimeExpressionParser from "./parsers/ESTimeExpressionParser"; -import ESMergeDateTimeRefiner from "./refiners/ESMergeDateTimeRefiner"; -import ESMergeDateRangeRefiner from "./refiners/ESMergeDateRangeRefiner"; -import ESMonthNameLittleEndianParser from "./parsers/ESMonthNameLittleEndianParser"; -import ESCasualDateParser from "./parsers/ESCasualDateParser"; -import ESCasualTimeParser from "./parsers/ESCasualTimeParser"; -import ESTimeUnitWithinFormatParser from "./parsers/ESTimeUnitWithinFormatParser"; + +import ESDefaultConfiguration from "./configuration"; export { Chrono, Parser, Refiner, ParsingResult, ParsingComponents, ReferenceWithTimezone }; export { Component, ParsedResult, ParsingOption, ParsingReference, Meridiem, Weekday }; -// Shortcuts -export const casual = new Chrono(createCasualConfiguration()); -export const strict = new Chrono(createConfiguration(true)); +export const configuration = new ESDefaultConfiguration(); -export function parse(text: string, ref?: ParsingReference | Date, option?: ParsingOption): ParsedResult[] { - return casual.parse(text, ref, option); -} +/** + * Chrono object configured for parsing *casual* Spanish + */ +export const casual = new Chrono(configuration.createCasualConfiguration(true)); -export function parseDate(text: string, ref?: ParsingReference | Date, option?: ParsingOption): Date { - return casual.parseDate(text, ref, option); -} +/** + * Chrono object configured for parsing *strict* Spanish + */ +export const strict = new Chrono(configuration.createConfiguration(true, true)); /** - * @ignore (to be documented later) + * A shortcut for es.casual.parse() */ -export function createCasualConfiguration(littleEndian = true): Configuration { - const option = createConfiguration(false, littleEndian); - option.parsers.push(new ESCasualDateParser()); - option.parsers.push(new ESCasualTimeParser()); - return option; +export function parse(text: string, ref?: ParsingReference | Date, option?: ParsingOption): ParsedResult[] { + return casual.parse(text, ref, option); } /** - * @ignore (to be documented later) + * A shortcut for es.casual.parseDate() */ -export function createConfiguration(strictMode = true, littleEndian = true): Configuration { - return includeCommonConfiguration( - { - parsers: [ - new SlashDateFormatParser(littleEndian), - new ESWeekdayParser(), - new ESTimeExpressionParser(), - new ESMonthNameLittleEndianParser(), - new ESTimeUnitWithinFormatParser(), - ], - refiners: [new ESMergeDateTimeRefiner(), new ESMergeDateRangeRefiner()], - }, - strictMode - ); +export function parseDate(text: string, ref?: ParsingReference | Date, option?: ParsingOption): Date { + return casual.parseDate(text, ref, option); } diff --git a/src/locales/es/parsers/ESCasualYearMonthDayParser.ts b/src/locales/es/parsers/ESCasualYearMonthDayParser.ts new file mode 100644 index 00000000..0910ccc7 --- /dev/null +++ b/src/locales/es/parsers/ESCasualYearMonthDayParser.ts @@ -0,0 +1,49 @@ +import { ParsingContext } from "../../../chrono"; +import { MONTH_DICTIONARY } from "../constants"; +import { matchAnyPattern } from "../../../utils/pattern"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +/* + Date format with slash "/" between numbers like ENSlashDateFormatParser, + but this parser expect year before month and date. + - YYYY/MM/DD + - YYYY-MM-DD + - YYYY.MM.DD +*/ +const PATTERN = new RegExp( + `([0-9]{4})[\\.\\/-]` + + `(?:(${matchAnyPattern(MONTH_DICTIONARY)})|([0-9]{1,2}))[\\.\\/-]` + + `([0-9]{1,2})` + + "(?=\\W|$)", + "i" +); + +const YEAR_NUMBER_GROUP = 1; +const MONTH_NAME_GROUP = 2; +const MONTH_NUMBER_GROUP = 3; +const DATE_NUMBER_GROUP = 4; + +export default class ESCasualYearMonthDayParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray) { + const month = match[MONTH_NUMBER_GROUP] + ? parseInt(match[MONTH_NUMBER_GROUP]) + : MONTH_DICTIONARY[match[MONTH_NAME_GROUP].toLowerCase()]; + + if (month < 1 || month > 12) { + return null; + } + + const year = parseInt(match[YEAR_NUMBER_GROUP]); + const day = parseInt(match[DATE_NUMBER_GROUP]); + + return { + day: day, + month: month, + year: year, + }; + } +} diff --git a/src/locales/es/parsers/ESMonthNameMiddleEndianParser.ts b/src/locales/es/parsers/ESMonthNameMiddleEndianParser.ts new file mode 100644 index 00000000..e1b9f16f --- /dev/null +++ b/src/locales/es/parsers/ESMonthNameMiddleEndianParser.ts @@ -0,0 +1,92 @@ +import { ParsingContext } from "../../../chrono"; +import { findYearClosestToRef } from "../../../calculation/years"; +import { MONTH_DICTIONARY } from "../constants"; +import { ORDINAL_NUMBER_PATTERN, parseOrdinalNumberPattern } from "../constants"; +import { YEAR_PATTERN, parseYear } from "../constants"; +import { matchAnyPattern } from "../../../utils/pattern"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +// prettier-ignore +const PATTERN = new RegExp( + `(${matchAnyPattern(MONTH_DICTIONARY)})` + + "(?:-|/|\\s*,?\\s*)" + + `(${ORDINAL_NUMBER_PATTERN})(?!\\s*(?:am|pm))\\s*` + + "(?:" + + "(?:al?|\\-)\\s*" + + `(${ORDINAL_NUMBER_PATTERN})\\s*` + + ")?" + + "(?:" + + `(?:-|/|\\s*,\\s*|\\s+)` + + `(${YEAR_PATTERN})` + + ")?" + + "(?=\\W|$)(?!\\:\\d)", + "i" +); + +const MONTH_NAME_GROUP = 1; +const DATE_GROUP = 2; +const DATE_TO_GROUP = 3; +const YEAR_GROUP = 4; + +/** + * The parser for parsing Spanish date format that begin with month's name. + * - Enero 13 + * - Enero 13, 2012 + * - Enero 13 - 15, 2012 + * - Enero 13 al 15, 2012 + */ +export default class ESMonthNameMiddleEndianParser extends AbstractParserWithWordBoundaryChecking { + shouldSkipYearLikeDate: boolean; + + constructor(shouldSkipYearLikeDate: boolean = false) { + super(); + this.shouldSkipYearLikeDate = shouldSkipYearLikeDate; + } + + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray) { + const month = MONTH_DICTIONARY[match[MONTH_NAME_GROUP].toLowerCase()]; + const day = parseOrdinalNumberPattern(match[DATE_GROUP]); + if (day > 31) { + return null; + } + + // Skip the case where the day looks like a year (ex: Enero 21) + if (this.shouldSkipYearLikeDate) { + if (!match[DATE_TO_GROUP] && !match[YEAR_GROUP] && match[DATE_GROUP].match(/^2[0-5]$/)) { + return null; + } + } + + const components = context + .createParsingComponents({ + day: day, + month: month, + }) + .addTag("parser/ESMonthNameMiddleEndianParser"); + + if (match[YEAR_GROUP]) { + const year = parseYear(match[YEAR_GROUP]); + components.assign("year", year); + } else { + const year = findYearClosestToRef(context.refDate, day, month); + components.imply("year", year); + } + + if (!match[DATE_TO_GROUP]) { + return components; + } + + // Text can be 'range' value. Such as 'Enero 12 - 13, 2012' + const endDate = parseOrdinalNumberPattern(match[DATE_TO_GROUP]); + const result = context.createParsingResult(match.index, match[0]); + result.start = components; + result.end = components.clone(); + result.end.assign("day", endDate); + + return result; + } +} diff --git a/src/locales/es/parsers/ESMonthNameParser.ts b/src/locales/es/parsers/ESMonthNameParser.ts new file mode 100644 index 00000000..2ab49bd6 --- /dev/null +++ b/src/locales/es/parsers/ESMonthNameParser.ts @@ -0,0 +1,63 @@ +import { FULL_MONTH_NAME_DICTIONARY, MONTH_DICTIONARY } from "../constants"; +import { ParsingContext } from "../../../chrono"; +import { findYearClosestToRef } from "../../../calculation/years"; +import { matchAnyPattern } from "../../../utils/pattern"; +import { YEAR_PATTERN, parseYear } from "../constants"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +const PATTERN = new RegExp( + `((?:en)\\s*)?` + + `(${matchAnyPattern(MONTH_DICTIONARY)})` + + `\\s*` + + `(?:` + + `(?:,|-|de|del)?\\s*(${YEAR_PATTERN})?` + + ")?" + + "(?=[^\\s\\w]|\\s+[^0-9]|\\s+$|$)", + "i" +); + +const PREFIX_GROUP = 1; +const MONTH_NAME_GROUP = 2; +const YEAR_GROUP = 3; + +/** + * The parser for parsing month name and year. + * - Enero, 2012 + * - Enero 2012 + * - Enero + * - (en) Ene + */ +export default class ESMonthNameParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray) { + const monthName = match[MONTH_NAME_GROUP].toLowerCase(); + + // skip some unlikely words "ene", "mar", .. + if (match[0].length <= 3 && !FULL_MONTH_NAME_DICTIONARY[monthName]) { + return null; + } + + const result = context.createParsingResult( + match.index + (match[PREFIX_GROUP] || "").length, + match.index + match[0].length + ); + result.start.imply("day", 1); + result.start.addTag("parser/ESMonthNameParser"); + + const month = MONTH_DICTIONARY[monthName]; + result.start.assign("month", month); + + if (match[YEAR_GROUP]) { + const year = parseYear(match[YEAR_GROUP]); + result.start.assign("year", year); + } else { + const year = findYearClosestToRef(context.refDate, 1, month); + result.start.imply("year", year); + } + + return result; + } +} diff --git a/src/locales/es/parsers/ESRelativeDateFormatParser.ts b/src/locales/es/parsers/ESRelativeDateFormatParser.ts new file mode 100644 index 00000000..3555e085 --- /dev/null +++ b/src/locales/es/parsers/ESRelativeDateFormatParser.ts @@ -0,0 +1,91 @@ +import { TIME_UNIT_DICTIONARY } from "../constants"; +import { ParsingContext } from "../../../chrono"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { matchAnyPattern } from "../../../utils/pattern"; + +// Pattern for "la semana pasada" (unit + modifier), "la próxima semana" (modifier + unit), "esta semana" +const PATTERN = new RegExp( + // Pattern 1: unit + modifier (pasado/pasada) + `(?:(?:el|la)\\s+)?(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s+(pasado|pasada)(?=\\W|$)|` + + // Pattern 2: modifier + unit (próximo/último/este/esta) + `(?:(?:el|la)\\s+)?(próximo|proxima|próxima|siguiente|último|ultima|este|esta)\\s+(${matchAnyPattern(TIME_UNIT_DICTIONARY)})(?=\\W|$)`, + "i" +); + +const UNIT_GROUP_1 = 1; +const MODIFIER_GROUP_1 = 2; +const MODIFIER_GROUP_2 = 3; +const UNIT_GROUP_2 = 4; + +export default class ESRelativeDateFormatParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + let modifier: string; + let unitWord: string; + + if (match[UNIT_GROUP_2]) { + // Pattern 2: modifier + unit ("la próxima semana", "este mes") + modifier = match[MODIFIER_GROUP_2]; + unitWord = match[UNIT_GROUP_2]; + } else { + // Pattern 1: unit + modifier ("la semana pasada", "el último mes") + unitWord = match[UNIT_GROUP_1]; + modifier = match[MODIFIER_GROUP_1]; + } + + modifier = modifier.toLowerCase(); + unitWord = unitWord.toLowerCase(); + const timeunit = TIME_UNIT_DICTIONARY[unitWord]; + + if ( + modifier == "próximo" || + modifier == "proxima" || + modifier == "próxima" || + modifier == "siguiente" + ) { + const timeUnits = {}; + timeUnits[timeunit] = 1; + return ParsingComponents.createRelativeFromReference(context.reference, timeUnits); + } + + if (modifier == "pasado" || modifier == "pasada" || modifier == "último" || modifier == "ultima") { + const timeUnits = {}; + timeUnits[timeunit] = -1; + return ParsingComponents.createRelativeFromReference(context.reference, timeUnits); + } + + const components = context.createParsingComponents(); + let date = new Date(context.reference.instant.getTime()); + + // Esta semana + if (unitWord.match(/semana/i)) { + date.setDate(date.getDate() - date.getDay()); + components.imply("day", date.getDate()); + components.imply("month", date.getMonth() + 1); + components.imply("year", date.getFullYear()); + } + + // Este mes + else if (unitWord.match(/mes/i)) { + date.setDate(1); + components.imply("day", date.getDate()); + components.assign("year", date.getFullYear()); + components.assign("month", date.getMonth() + 1); + } + + // Este año + else if (unitWord.match(/año/i)) { + date.setDate(1); + date.setMonth(0); + components.imply("day", date.getDate()); + components.imply("month", date.getMonth() + 1); + components.assign("year", date.getFullYear()); + } + + return components; + } +} diff --git a/src/locales/es/parsers/ESSlashMonthFormatParser.ts b/src/locales/es/parsers/ESSlashMonthFormatParser.ts new file mode 100644 index 00000000..51d2a28f --- /dev/null +++ b/src/locales/es/parsers/ESSlashMonthFormatParser.ts @@ -0,0 +1,26 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +const PATTERN = new RegExp("([0-9]|0[1-9]|1[012])/([0-9]{4})" + "", "i"); + +const MONTH_GROUP = 1; +const YEAR_GROUP = 2; + +/** + * Month/Year date format with slash "/" (also "-" and ".") between numbers + * - 11/05 + * - 06/2005 + */ +export default class ESSlashMonthFormatParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + const year = parseInt(match[YEAR_GROUP]); + const month = parseInt(match[MONTH_GROUP]); + + return context.createParsingComponents().imply("day", 1).assign("month", month).assign("year", year); + } +} diff --git a/src/locales/es/parsers/ESTimeUnitAgoFormatParser.ts b/src/locales/es/parsers/ESTimeUnitAgoFormatParser.ts new file mode 100644 index 00000000..f7b87676 --- /dev/null +++ b/src/locales/es/parsers/ESTimeUnitAgoFormatParser.ts @@ -0,0 +1,27 @@ +import { ParsingContext } from "../../../chrono"; +import { parseDuration, TIME_UNITS_NO_ABBR_PATTERN, TIME_UNITS_PATTERN } from "../constants"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { reverseDuration } from "../../../calculation/duration"; + +const PATTERN = new RegExp(`(?:hace|atrás)\\s{0,5}(${TIME_UNITS_PATTERN})(?=\\W|$)`, "i"); + +const STRICT_PATTERN = new RegExp(`(?:hace|atrás)\\s{0,5}(${TIME_UNITS_NO_ABBR_PATTERN})(?=\\W|$)`, "i"); + +export default class ESTimeUnitAgoFormatParser extends AbstractParserWithWordBoundaryChecking { + constructor(private strictMode: boolean) { + super(); + } + + innerPattern(): RegExp { + return this.strictMode ? STRICT_PATTERN : PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray) { + const duration = parseDuration(match[1]); + if (!duration) { + return null; + } + return ParsingComponents.createRelativeFromReference(context.reference, reverseDuration(duration)); + } +} diff --git a/src/locales/es/parsers/ESTimeUnitCasualRelativeFormatParser.ts b/src/locales/es/parsers/ESTimeUnitCasualRelativeFormatParser.ts new file mode 100644 index 00000000..509ca777 --- /dev/null +++ b/src/locales/es/parsers/ESTimeUnitCasualRelativeFormatParser.ts @@ -0,0 +1,45 @@ +import { TIME_UNITS_PATTERN, parseDuration, TIME_UNITS_NO_ABBR_PATTERN } from "../constants"; +import { ParsingContext } from "../../../chrono"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { reverseDuration } from "../../../calculation/duration"; + +const PATTERN = new RegExp( + `(este|esta|pasado|pasada|último|ultima|próximo|proxima|próxima|siguiente|\\+|-)\\s*(${TIME_UNITS_PATTERN})(?=\\W|$)`, + "i" +); + +const PATTERN_NO_ABBR = new RegExp( + `(este|esta|pasado|pasada|último|ultima|próximo|proxima|próxima|siguiente|\\+|-)\\s*(${TIME_UNITS_NO_ABBR_PATTERN})(?=\\W|$)`, + "i" +); + +export default class ESTimeUnitCasualRelativeFormatParser extends AbstractParserWithWordBoundaryChecking { + constructor(private allowAbbreviations: boolean = true) { + super(); + } + + innerPattern(): RegExp { + return this.allowAbbreviations ? PATTERN : PATTERN_NO_ABBR; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray) { + const prefix = match[1].toLowerCase(); + let duration = parseDuration(match[2]); + if (!duration) { + return null; + } + + switch (prefix) { + case "pasado": + case "pasada": + case "último": + case "ultima": + case "-": + duration = reverseDuration(duration); + break; + } + + return ParsingComponents.createRelativeFromReference(context.reference, duration); + } +} diff --git a/src/locales/es/parsers/ESTimeUnitLaterFormatParser.ts b/src/locales/es/parsers/ESTimeUnitLaterFormatParser.ts new file mode 100644 index 00000000..ee7d5ba9 --- /dev/null +++ b/src/locales/es/parsers/ESTimeUnitLaterFormatParser.ts @@ -0,0 +1,28 @@ +import { ParsingContext } from "../../../chrono"; +import { parseDuration, TIME_UNITS_PATTERN } from "../constants"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +const PATTERN = new RegExp(`(?:en|dentro de)\\s{0,5}(${TIME_UNITS_PATTERN})(?=(?:\\W|$))`, "i"); + +const STRICT_PATTERN = new RegExp(`(?:en|dentro de)\\s{0,5}(${TIME_UNITS_PATTERN})(?=(?:\\W|$))`, "i"); + +const GROUP_NUM_TIMEUNITS = 1; + +export default class ESTimeUnitLaterFormatParser extends AbstractParserWithWordBoundaryChecking { + constructor(private strictMode: boolean) { + super(); + } + + innerPattern(): RegExp { + return this.strictMode ? STRICT_PATTERN : PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray) { + const fragments = parseDuration(match[GROUP_NUM_TIMEUNITS]); + if (!fragments) { + return null; + } + return ParsingComponents.createRelativeFromReference(context.reference, fragments); + } +} diff --git a/src/locales/es/refiners/ESMergeRelativeDateRefiner.ts b/src/locales/es/refiners/ESMergeRelativeDateRefiner.ts new file mode 100644 index 00000000..8ad235f4 --- /dev/null +++ b/src/locales/es/refiners/ESMergeRelativeDateRefiner.ts @@ -0,0 +1,58 @@ +import { MergingRefiner } from "../../../common/abstractRefiners"; +import { ParsingComponents, ParsingResult, ReferenceWithTimezone } from "../../../results"; +import { parseDuration } from "../constants"; +import { reverseDuration } from "../../../calculation/duration"; + +function hasImpliedEarlierReferenceDate(result: ParsingResult): boolean { + return result.text.match(/\s+(antes|desde)$/i) != null; +} + +function hasImpliedLaterReferenceDate(result: ParsingResult): boolean { + return result.text.match(/\s+(después|desde|hasta)$/i) != null; +} + +/** + * Merges an absolute date with a relative date. + * - 2 semanas antes del 2020-02-13 + * - 2 días después del próximo viernes + */ +export default class ESMergeRelativeDateRefiner extends MergingRefiner { + patternBetween(): RegExp { + return /^\s*$/i; + } + + shouldMergeResults(textBetween: string, currentResult: ParsingResult, nextResult: ParsingResult): boolean { + // Dates need to be next to each other to get merged + if (!textBetween.match(this.patternBetween())) { + return false; + } + + // Check if any relative tokens were swallowed by the first date. + // E.g. [ desde] [] + if (!hasImpliedEarlierReferenceDate(currentResult) && !hasImpliedLaterReferenceDate(currentResult)) { + return false; + } + + // make sure that implies an absolute date + return !!nextResult.start.get("day") && !!nextResult.start.get("month") && !!nextResult.start.get("year"); + } + + mergeResults(textBetween: string, currentResult: ParsingResult, nextResult: ParsingResult): ParsingResult { + let timeUnits = parseDuration(currentResult.text); + if (hasImpliedEarlierReferenceDate(currentResult)) { + timeUnits = reverseDuration(timeUnits); + } + + const components = ParsingComponents.createRelativeFromReference( + ReferenceWithTimezone.fromDate(nextResult.start.date()), + timeUnits + ); + + return new ParsingResult( + nextResult.reference, + currentResult.index, + `${currentResult.text}${textBetween}${nextResult.text}`, + components + ); + } +} diff --git a/test/es/es_relative.test.ts b/test/es/es_relative.test.ts new file mode 100644 index 00000000..7af54a2d --- /dev/null +++ b/test/es/es_relative.test.ts @@ -0,0 +1,91 @@ +import * as chrono from "../../src"; +import { testSingleCase } from "../test_util"; + +test("Test - 'Este/Esta' expressions", () => { + testSingleCase(chrono.es, "esta semana", new Date(2017, 11 - 1, 19, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2017); + expect(result.start.get("month")).toBe(11); + expect(result.start.get("day")).toBe(19); + expect(result.start.get("hour")).toBe(12); + }); + + testSingleCase(chrono.es, "este mes", new Date(2017, 11 - 1, 19, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2017); + expect(result.start.get("month")).toBe(11); + expect(result.start.get("day")).toBe(1); + expect(result.start.get("hour")).toBe(12); + }); + + testSingleCase(chrono.es, "este año", new Date(2017, 11 - 1, 19, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2017); + expect(result.start.get("month")).toBe(1); + expect(result.start.get("day")).toBe(1); + expect(result.start.get("hour")).toBe(12); + }); +}); + +test("Test - Past relative expressions", () => { + testSingleCase(chrono.es, "la semana pasada", new Date(2016, 10 - 1, 1, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2016); + expect(result.start.get("month")).toBe(9); + expect(result.start.get("day")).toBe(24); + expect(result.start.get("hour")).toBe(12); + }); + + testSingleCase(chrono.es, "el mes pasado", new Date(2016, 10 - 1, 1, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2016); + expect(result.start.get("month")).toBe(9); + expect(result.start.get("day")).toBe(1); + expect(result.start.get("hour")).toBe(12); + }); + + testSingleCase(chrono.es, "el último mes", new Date(2016, 10 - 1, 1, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2016); + expect(result.start.get("month")).toBe(9); + expect(result.start.get("day")).toBe(1); + expect(result.start.get("hour")).toBe(12); + }); +}); + +test("Test - Future relative expressions", () => { + testSingleCase(chrono.es, "la próxima semana", new Date(2016, 10 - 1, 1, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2016); + expect(result.start.get("month")).toBe(10); + expect(result.start.get("day")).toBe(8); + expect(result.start.get("hour")).toBe(12); + }); + + testSingleCase(chrono.es, "el próximo mes", new Date(2016, 10 - 1, 1, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2016); + expect(result.start.get("month")).toBe(11); + expect(result.start.get("day")).toBe(1); + expect(result.start.get("hour")).toBe(12); + }); + + testSingleCase(chrono.es, "el próximo año", new Date(2020, 11 - 1, 22, 12, 11, 32, 6), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2021); + expect(result.start.get("month")).toBe(11); + expect(result.start.get("day")).toBe(22); + expect(result.start.get("hour")).toBe(12); + expect(result.start.get("minute")).toBe(11); + expect(result.start.get("second")).toBe(32); + expect(result.start.get("millisecond")).toBe(6); + }); + + testSingleCase(chrono.es, "el siguiente mes", new Date(2016, 10 - 1, 1, 12), (result, text) => { + expect(result.text).toBe(text); + expect(result.start.get("year")).toBe(2016); + expect(result.start.get("month")).toBe(11); + expect(result.start.get("day")).toBe(1); + expect(result.start.get("hour")).toBe(12); + }); +}); diff --git a/test/es/es_time_units_ago.test.ts b/test/es/es_time_units_ago.test.ts new file mode 100644 index 00000000..2a2c530f --- /dev/null +++ b/test/es/es_time_units_ago.test.ts @@ -0,0 +1,174 @@ +import * as chrono from "../../src/"; +import { testSingleCase, testUnexpectedResult } from "../test_util"; +import { Meridiem } from "../../src/"; + +test("Test - Single Expression", function () { + testSingleCase(chrono.es, "hace 5 días, hicimos algo", new Date(2012, 7, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(8); + expect(result.start.get("day")).toBe(5); + + expect(result.index).toBe(0); + expect(result.text).toBe("hace 5 días"); + expect(result.tags()).toContain("result/relativeDate"); + + expect(result.start).toBeDate(new Date(2012, 8 - 1, 5)); + }); + + testSingleCase(chrono.es, "hace 10 días, hicimos algo", new Date(2012, 7, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(7); + expect(result.start.get("day")).toBe(31); + + expect(result.index).toBe(0); + expect(result.text).toBe("hace 10 días"); + + expect(result.start).toBeDate(new Date(2012, 7 - 1, 31)); + }); + + testSingleCase(chrono.es, "hace 15 minutos", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("hace 15 minutos"); + expect(result.tags()).toContain("result/relativeDate"); + expect(result.tags()).toContain("result/relativeDateAndTime"); + + expect(result.start.get("hour")).toBe(11); + expect(result.start.get("minute")).toBe(59); + expect(result.start.get("meridiem")).toBe(Meridiem.AM); + + expect(result.start).toBeDate(new Date(2012, 7, 10, 11, 59)); + }); + + testSingleCase(chrono.es, " hace 12 horas", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(3); + expect(result.text).toBe("hace 12 horas"); + expect(result.start.get("hour")).toBe(0); + expect(result.start.get("minute")).toBe(14); + expect(result.start.get("meridiem")).toBe(Meridiem.AM); + + expect(result.start).toBeDate(new Date(2012, 7, 10, 0, 14)); + }); + + testSingleCase(chrono.es, "hace 1h", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("hace 1h"); + expect(result.start.get("hour")).toBe(11); + expect(result.start.get("minute")).toBe(14); + expect(result.start.get("meridiem")).toBe(Meridiem.AM); + }); + + testSingleCase(chrono.es, "hace 12 segundos hice algo", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("hace 12 segundos"); + expect(result.start.get("hour")).toBe(12); + expect(result.start.get("minute")).toBe(13); + expect(result.start.get("second")).toBe(48); + expect(result.start.get("meridiem")).toBe(Meridiem.PM); + + expect(result.start).toBeDate(new Date(2012, 7, 10, 12, 13, 48)); + }); + + testSingleCase(chrono.es, "hace tres segundos hice algo", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("hace tres segundos"); + expect(result.start.get("hour")).toBe(12); + expect(result.start.get("minute")).toBe(13); + expect(result.start.get("second")).toBe(57); + expect(result.start.get("meridiem")).toBe(Meridiem.PM); + + expect(result.start).toBeDate(new Date(2012, 7, 10, 12, 13, 57)); + }); +}); + +test("Test - Single Expression (Casual)", function () { + testSingleCase(chrono.es, "hace 5 meses, hicimos algo", new Date(2012, 10 - 1, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(5); + expect(result.start.get("day")).toBe(10); + + expect(result.index).toBe(0); + expect(result.text).toBe("hace 5 meses"); + + expect(result.start).toBeDate(new Date(2012, 5 - 1, 10)); + }); + + testSingleCase(chrono.es, "hace 5 años, hicimos algo", new Date(2012, 8 - 1, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2007); + expect(result.start.get("month")).toBe(8); + expect(result.start.get("day")).toBe(10); + + expect(result.index).toBe(0); + expect(result.text).toBe("hace 5 años"); + + expect(result.start).toBeDate(new Date(2007, 8 - 1, 10)); + }); + + testSingleCase(chrono.es, "hace una semana, hicimos algo", new Date(2012, 8 - 1, 3), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(7); + expect(result.start.get("day")).toBe(27); + + expect(result.index).toBe(0); + expect(result.text).toBe("hace una semana"); + + expect(result.start).toBeDate(new Date(2012, 7 - 1, 27)); + }); + + testSingleCase(chrono.es, "hace pocos días, hicimos algo", new Date(2012, 8 - 1, 3), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(7); + expect(result.start.get("day")).toBe(31); + + expect(result.index).toBe(0); + expect(result.text).toBe("hace pocos días"); + + expect(result.start).toBeDate(new Date(2012, 7 - 1, 31)); + }); +}); + +test("Test - Nested time ago", function () { + testSingleCase(chrono.es, "hace 15 horas 29 minutos", new Date(2012, 7, 10, 22, 30), (result) => { + expect(result.text).toBe("hace 15 horas 29 minutos"); + expect(result.start.get("day")).toBe(10); + expect(result.start.get("hour")).toBe(7); + expect(result.start.get("minute")).toBe(1); + expect(result.start.get("meridiem")).toBe(Meridiem.AM); + }); + + testSingleCase(chrono.es, "hace 1 día 21 horas ", new Date(2012, 7, 10, 22, 30), (result) => { + expect(result.text).toBe("hace 1 día 21 horas"); + expect(result.start.get("day")).toBe(9); + expect(result.start.get("hour")).toBe(1); + expect(result.start.get("minute")).toBe(30); + expect(result.start.get("meridiem")).toBe(Meridiem.AM); + }); + + testSingleCase(chrono.es, "hace 1d 21 h 25m ", new Date(2012, 7, 10, 22, 30), (result) => { + expect(result.text).toBe("hace 1d 21 h 25m"); + expect(result.start.get("day")).toBe(9); + expect(result.start.get("hour")).toBe(1); + expect(result.start.get("minute")).toBe(5); + expect(result.start.get("meridiem")).toBe(Meridiem.AM); + }); +}); + +test("Test - Strict mode", function () { + testSingleCase(chrono.es.strict, "hace 5 minutos", new Date(2012, 7, 10, 12, 14), (result, text) => { + expect(result.start.get("hour")).toBe(12); + expect(result.start.get("minute")).toBe(9); + expect(result.start).toBeDate(new Date(2012, 7, 10, 12, 9)); + }); + + testUnexpectedResult(chrono.es.strict, "hace 5m", new Date(2012, 7, 10, 12, 14)); + testUnexpectedResult(chrono.es.strict, "hace 5 h", new Date(2012, 7, 10, 12, 14)); +}); + +test("Test - Negative cases", function () { + testUnexpectedResult(chrono.es, "hace unas horas"); +}); diff --git a/test/es/es_time_units_later.test.ts b/test/es/es_time_units_later.test.ts new file mode 100644 index 00000000..f6cecc46 --- /dev/null +++ b/test/es/es_time_units_later.test.ts @@ -0,0 +1,89 @@ +import * as chrono from "../../src/"; +import { testSingleCase, testUnexpectedResult } from "../test_util"; +import { Meridiem } from "../../src/"; + +test("Test - Single Expression", function () { + testSingleCase(chrono.es, "en 5 días, haremos algo", new Date(2012, 7, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(8); + expect(result.start.get("day")).toBe(15); + + expect(result.index).toBe(0); + expect(result.text).toBe("en 5 días"); + expect(result.tags()).toContain("result/relativeDate"); + + expect(result.start).toBeDate(new Date(2012, 8 - 1, 15)); + }); + + testSingleCase(chrono.es, "dentro de 10 días haremos algo", new Date(2012, 7, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(8); + expect(result.start.get("day")).toBe(20); + + expect(result.index).toBe(0); + expect(result.text).toBe("dentro de 10 días"); + + expect(result.start).toBeDate(new Date(2012, 8 - 1, 20)); + }); + + testSingleCase(chrono.es, "en 15 minutos", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("en 15 minutos"); + + expect(result.start.get("hour")).toBe(12); + expect(result.start.get("minute")).toBe(29); + expect(result.start.get("meridiem")).toBe(Meridiem.PM); + + expect(result.start).toBeDate(new Date(2012, 7, 10, 12, 29)); + }); + + testSingleCase(chrono.es, " en 12 horas", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(3); + expect(result.text).toBe("en 12 horas"); + expect(result.start.get("hour")).toBe(0); + expect(result.start.get("minute")).toBe(14); + expect(result.start.get("meridiem")).toBe(Meridiem.AM); + + expect(result.start).toBeDate(new Date(2012, 7, 11, 0, 14)); + }); + + testSingleCase(chrono.es, "en 1h", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("en 1h"); + expect(result.start.get("hour")).toBe(13); + expect(result.start.get("minute")).toBe(14); + expect(result.start.get("meridiem")).toBe(Meridiem.PM); + }); +}); + +test("Test - Single Expression (Casual)", function () { + testSingleCase(chrono.es, "en 5 meses, haremos algo", new Date(2012, 8 - 1, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2013); + expect(result.start.get("month")).toBe(1); + expect(result.start.get("day")).toBe(10); + + expect(result.index).toBe(0); + expect(result.text).toBe("en 5 meses"); + + expect(result.start).toBeDate(new Date(2013, 1 - 1, 10)); + }); + + testSingleCase(chrono.es, "en 2 años, haremos algo", new Date(2012, 8 - 1, 10), (result) => { + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2014); + expect(result.start.get("month")).toBe(8); + expect(result.start.get("day")).toBe(10); + + expect(result.index).toBe(0); + expect(result.text).toBe("en 2 años"); + + expect(result.start).toBeDate(new Date(2014, 8 - 1, 10)); + }); +}); + +test("Test - Negative cases", function () { + testUnexpectedResult(chrono.es, "en unas horas"); +}); diff --git a/test/es/es_year_month_day.test.ts b/test/es/es_year_month_day.test.ts new file mode 100644 index 00000000..f259c15d --- /dev/null +++ b/test/es/es_year_month_day.test.ts @@ -0,0 +1,37 @@ +import * as chrono from "../../src"; +import { testSingleCase } from "../test_util"; + +test("Test - Year-Month-Day format", () => { + testSingleCase(chrono.es, "2020-12-25", (result) => { + expect(result.start.get("year")).toBe(2020); + expect(result.start.get("month")).toBe(12); + expect(result.start.get("day")).toBe(25); + + expect(result.text).toBe("2020-12-25"); + }); + + testSingleCase(chrono.es, "2021/01/15", (result) => { + expect(result.start.get("year")).toBe(2021); + expect(result.start.get("month")).toBe(1); + expect(result.start.get("day")).toBe(15); + + expect(result.text).toBe("2021/01/15"); + }); + + testSingleCase(chrono.es, "2019.03.20", (result) => { + expect(result.start.get("year")).toBe(2019); + expect(result.start.get("month")).toBe(3); + expect(result.start.get("day")).toBe(20); + + expect(result.text).toBe("2019.03.20"); + }); + + testSingleCase(chrono.es, "La fecha es 2022-06-30", (result) => { + expect(result.start.get("year")).toBe(2022); + expect(result.start.get("month")).toBe(6); + expect(result.start.get("day")).toBe(30); + + expect(result.text).toBe("2022-06-30"); + expect(result.index).toBe(12); + }); +}); From 727da73a6ef63f835b949ef5f83267df8e7fe28a Mon Sep 17 00:00:00 2001 From: Juan Segnana <37079836+juansegnana@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:05:53 -0300 Subject: [PATCH 2/2] fix prettier --- src/locales/es/parsers/ESRelativeDateFormatParser.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/locales/es/parsers/ESRelativeDateFormatParser.ts b/src/locales/es/parsers/ESRelativeDateFormatParser.ts index 3555e085..586dc8d7 100644 --- a/src/locales/es/parsers/ESRelativeDateFormatParser.ts +++ b/src/locales/es/parsers/ESRelativeDateFormatParser.ts @@ -8,8 +8,8 @@ import { matchAnyPattern } from "../../../utils/pattern"; const PATTERN = new RegExp( // Pattern 1: unit + modifier (pasado/pasada) `(?:(?:el|la)\\s+)?(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s+(pasado|pasada)(?=\\W|$)|` + - // Pattern 2: modifier + unit (próximo/último/este/esta) - `(?:(?:el|la)\\s+)?(próximo|proxima|próxima|siguiente|último|ultima|este|esta)\\s+(${matchAnyPattern(TIME_UNIT_DICTIONARY)})(?=\\W|$)`, + // Pattern 2: modifier + unit (próximo/último/este/esta) + `(?:(?:el|la)\\s+)?(próximo|proxima|próxima|siguiente|último|ultima|este|esta)\\s+(${matchAnyPattern(TIME_UNIT_DICTIONARY)})(?=\\W|$)`, "i" ); @@ -41,12 +41,7 @@ export default class ESRelativeDateFormatParser extends AbstractParserWithWordBo unitWord = unitWord.toLowerCase(); const timeunit = TIME_UNIT_DICTIONARY[unitWord]; - if ( - modifier == "próximo" || - modifier == "proxima" || - modifier == "próxima" || - modifier == "siguiente" - ) { + if (modifier == "próximo" || modifier == "proxima" || modifier == "próxima" || modifier == "siguiente") { const timeUnits = {}; timeUnits[timeunit] = 1; return ParsingComponents.createRelativeFromReference(context.reference, timeUnits);