diff --git a/README.md b/README.md index 2de3e11..d00a983 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Capture any positive integer number. Language | Examples -----------------|------------- -English | `20`, `zero`, `one million`, `4 000`, '1 dozen', '100k' +English | `20`, `zero`, `one million`, `4 000`, `1 dozen`, `100k` #### Returned value @@ -101,7 +101,7 @@ Capture any number, including numbers with a fractional element. Language | Examples -----------------|------------- -English | `20`, `2.4 million`, `8.0`, '-12' +English | `20`, `2.4 million`, `8.0`, `-12` #### Returned value @@ -128,7 +128,7 @@ Capture an ordinal, such as `1st`, indicating a position. Language | Examples -----------------|------------- -English | `1st`, `third`, `3`, 'the fifth' +English | `1st`, `third`, `3`, `the fifth` #### Returned value @@ -159,8 +159,13 @@ English | `today`, `in 2 days`, `january 12th`, `2010-02-22`, `02/22/20 #### Returned value -The returned value is an object with the keys `year`, `month`, `day` and a -parsed date `` +The returned value is an object with the keys `year`, `month`, `day` and can +be turned into a `Date` via the function `toDate`. + +```javascript +const date = value.toDate(); +``` + #### Example ```javascript @@ -180,6 +185,17 @@ Language | Examples -----------------|------------- English | `09:00`, `3 pm`, `at 3:30 am`, `noon`, `quarter to twelve`, `in 2 hours`, `in 45 minutes` +#### Returned value + +The returned value is an object with the keys `hour`, `minute`, `second` and can +be turned into a `Date` via the function `toDate`. + +```javascript +const date = value.toDate(); +``` + +#### Example + ```javascript const time = require('ecolect/values/time'); @@ -189,6 +205,23 @@ builder.intent('alarm') .done(); ``` +### Date & Time + +Capture both a date and a time. + +Language | Examples +-----------------|------------- +English | `3pm on Jan 12th`, `in 2 days and 2 hours`, `14:00` + +```javascript +const datetime = require('ecolect/values/datetime'); + +builder.intent('schedule') + .value('when', datetime()) + .add('Schedule a call {when}') + .done(); +``` + ### Enumeration Capture one of the specified values. Used to specify one or more values that diff --git a/language/dates.js b/language/dates.js index 3acc0a9..7b39fde 100644 --- a/language/dates.js +++ b/language/dates.js @@ -1,91 +1,187 @@ 'use strict'; -module.exports.currentTime = function(encounter) { +const cloneDeep = require('lodash.clonedeep'); + +const addMonths = require('date-fns/add_months') +const addWeeks = require('date-fns/add_weeks'); +const addDays = require('date-fns/add_days') +const addSeconds = require('date-fns/add_seconds') + +const setISODay = require('date-fns/set_iso_day'); +const getISODay = require('date-fns/get_iso_day'); +const setHours = require('date-fns/set_hours') +const setMinutes = require('date-fns/set_minutes') +const setSeconds = require('date-fns/set_seconds') + +module.exports.isRelative = function isRelative(v) { + return v && (v.relativeMonths >= 0 || v.relativeWeeks >= 0 || v.relativeDays >= 0); +}; + +module.exports.combine = function(a, b) { + const result = cloneDeep(a); + Object.keys(b).forEach(key => result[key] = b[key]); + return result; +}; + +function currentTime(encounter) { if(encounter.options.now) { return encounter.options.now; } else { return encounter.options.now = new Date(); } -}; +} +module.exports.currentTime = currentTime; -module.exports.toDate = function toDate(date, now) { +function toDate(date, now) { return new Date( typeof date.year !== 'undefined' ? date.year : now.getFullYear(), typeof date.month !== 'undefined' ? date.month : now.getMonth(), typeof date.day !== 'undefined' ? date.day : now.getDate() ); -}; +} -module.exports.mapYear = function(r, e) { +module.exports.toDate = toDate; + +function mapYear(r, e) { const now = module.exports.currentTime(e); if(r.adjusters) { r.adjusters.forEach(a => a(r, now)); } - const result = {}; + const result = new DateValue(e.language); + + if(typeof r.relativeYear !== 'undefined') { + result.year = now.getFullYear() + r.relativeYear; + result.month = now.getMonth(); + } + + if(typeof r.relativeMonths !== 'undefined') { + const time = addMonths(toDate(result, now), r.relativeMonths); + result.year = time.getFullYear(); + result.month = time.getMonth(); + } + if(typeof r.year !== 'undefined') { result.year = r.year; } + if(typeof r.month !== 'undefined') { result.month = r.month; } - return new DateValue(e.language, result); -}; + return result; +} -module.exports.mapMonth = module.exports.mapYear; +module.exports.mapYear = mapYear; +module.exports.mapMonth = mapYear; -module.exports.mapDate = function(r, e) { - const now = module.exports.currentTime(e); - if(r.adjusters) { - r.adjusters.forEach(a => a(r, now)); - } +function resolveDate(r, e) { + const result = mapYear(r, e); - const result = {}; - if(typeof r.year !== 'undefined') { - result.year = r.year; - } else { + const now = toDate(result, currentTime(e)); + + if(typeof result.year === 'undefined') { result.year = now.getFullYear(); } - if(typeof r.month !== 'undefined') { - result.month = r.month; - } else { + + if(typeof result.month === 'undefined') { result.month = now.getMonth(); } - if(typeof r.day !== 'undefined') { - result.day = r.day; + + if(typeof r.relativeDays !== 'undefined') { + const date = addDays(toDate(result, now), r.relativeDays); + result.year = date.getFullYear(); + result.month = date.getMonth(); + result.day = date.getDate(); } else { - result.day = now.getDate(); + if(typeof r.day !== 'undefined') { + result.day = r.day; + } else { + result.day = now.getDate(); + } } + + if(typeof r.dayOfWeek !== 'undefined') { - result.dayOfWeek = r.dayOfWeek; + if(typeof r.month === 'undefined') { + // Reset to first month + result.month = 0; + } + + if(typeof r.day === 'undefined') { + // Reset to first day + result.day = 1; + } + + let date = toDate(result, now); + + const currentDayOfWeek = getISODay(date); + if(currentDayOfWeek > r.dayOfWeek) { + date = addWeeks(date, 1); + } + date = setISODay(date, r.dayOfWeek); + + for(let i=1; i 0) { + time = addSeconds(time, r.relative); + } else { + if(typeof r.hour !== 'undefined') { + time = setHours(time, r.hour); + } -function copy(from, to, value) { - if(typeof from[value] !== 'undefined') { - to[value] = from[value]; + if(typeof r.minute !== 'undefined') { + time = setMinutes(time, r.minute); + } else { + time = setMinutes(time, 0); + } + + if(typeof r.second !== 'undefined') { + time = setSeconds(time, r.second); + } else { + time = setSeconds(time, 0); + } } + + result.hour = time.getHours(); + result.minute = time.getMinutes(); + result.precision = r.precision || result.precision || 'normal'; + return result; } -class DateValue { - constructor(language, data) { - copy(data, this, 'year'); - copy(data, this, 'month'); - copy(data, this, 'day'); +module.exports.mapTime = resolveTime; - copy(data, this, 'hour'); - copy(data, this, 'minute'); - copy(data, this, 'second'); +module.exports.mapDateTime = function(r, e) { + const result = resolveDate(r, e); + return resolveTime(r, e, result.toDate(), result); +} + +class DateValue { + constructor(language) { Object.defineProperty(this, 'language', { value: language }); } - toDate() { - return module.exports.toDate(this, new Date()); + toDate(now) { + return module.exports.toDate(this, now || new Date()); } } diff --git a/language/en.js b/language/en.js index acd417e..1214a92 100644 --- a/language/en.js +++ b/language/en.js @@ -2,7 +2,7 @@ const utils = require('./utils'); -const dateLocale = require('date-fns/locale/en'); +//const dateLocale = require('date-fns/locale/en'); const integer = require('./en/integer'); const number = require('./en/number'); @@ -15,6 +15,7 @@ const month = require('./en/month'); const year = require('./en/year'); const date = require('./en/date'); const time = require('./en/time'); +const datetime = require('./en/datetime'); const stemmer = require('talisman/stemmers/porter'); const similarity = require('talisman/metrics/distance/jaro-winkler').similarity; @@ -84,10 +85,10 @@ module.exports = { compareTokens(a, b) { if(a.normalized === b.normalized) return 1.0; - if(a.short || b.short) return 0; - if(a.stemmed === b.stemmed) return 0.95; + if(a.short || b.short) return 0; + const d = similarity(a.normalized, b.normalized); if(d > 0.9) return d * 0.9; @@ -114,5 +115,6 @@ module.exports.month = month(module.exports); module.exports.year = year(module.exports); module.exports.date = date(module.exports); module.exports.time = time(module.exports); +module.exports.datetime = datetime(module.exports); module.exports.temperature = temperature(module.exports); diff --git a/language/en/date.js b/language/en/date.js index d571cbf..0b62b34 100644 --- a/language/en/date.js +++ b/language/en/date.js @@ -38,16 +38,6 @@ function hasMonth(v) { return true; } -function isMonthDay(v) { - return typeof v.year === 'undefined' && v.month >= 0 && v.day >= 0; -} - -function isNoYear(v) { - if(typeof v.year !== 'undefined') return false; - - return typeof v.month !== 'undefined'; -} - function withDay(date, day) { const result = cloneDeep(date); result.day = value(day); @@ -68,14 +58,6 @@ function currentTime(encounter) { } } -function adjustedMonth(date, diff) { - date = addMonths(date, diff); - return { - year: date.getFullYear(), - month: date.getMonth() - }; -} - function adjustedDays(date, diff) { date = addDays(date, diff); return { @@ -102,56 +84,9 @@ function nextDayOfWeek(v, e) { }; } -function withAdjuster(date, adjuster) { - const result = cloneDeep(date); - if(! result.adjusters) { - result.adjusters = [ adjuster ]; - } else { - result.adjusters.push(adjuster); - } - return result; -} - -function dayInMonth(now, result, ordinal, day) { - result.day = 1; - let date = toDate(result, now); - const currentDayOfWeek = getISODay(date); - if(currentDayOfWeek > day) { - date = addWeeks(date, 1); - } - date = setISODay(date, day); - - for(let i=1; i result[key] = b[key]); - return result; -} - -function toDate(date, now) { - return new Date( - typeof date.year !== 'undefined' ? date.year : now.getFullYear(), - typeof date.month !== 'undefined' ? date.month : now.getMonth(), - typeof date.day !== 'undefined' ? date.day : now.getDate() - ); -} - module.exports = function(language) { const ordinal = language.ordinal; + const integer = language.integer; const dayOfWeek = language.dayOfWeek; const month = language.month; const year = language.year; @@ -172,7 +107,6 @@ module.exports = function(language) { .add('day after tomorrow', (v, e) => adjustedDays(currentTime(e), 2)) .add('the day after tomorrow', (v, e) => adjustedDays(currentTime(e), 2)) .add('yesterday', (v, e) => adjustedDays(currentTime(e), -1)) - .add([ 'in', ordinal, 'days' ], (v, e) => adjustedDays(currentTime(e), v[0].value)) // Month followed by day - Jan 12, February 1st .add([ month, Parser.result(ordinal, v => v.value >= 0 && v.value < 31) ], v => withDay(v[0], v[1])) @@ -184,7 +118,7 @@ module.exports = function(language) { // Non-year (month and day) followed by year // With day: 12 Jan 2018, 1st February 2018 // Without day: Jan 2018, this month 2018 - .add([ Parser.result(hasMonth), year ], v => withYear(v[0], v[1])) + .add([ Parser.result(hasMonth), year ], v => utils.combine(v[0], v[1])) .add([ month, /^[0-9]{1,2}$/ ], v => withYear(v[0], v[1])) .add([ month, 'in', /^[0-9]{1,2}$/ ], v => withYear(v[0], v[1])) @@ -216,20 +150,30 @@ module.exports = function(language) { }; }) + // Relative dates + .add([ integer, 'days' ], v => { return { relativeDays: v[0].value }}) + .add([ integer, 'months' ], v => { return { relativeMonths: v[0].value }}) + .add([ integer, 'weeks' ], v => { return { relativeDays: v[0].value * 7 }}) + + .add([ Parser.result(utils.isRelative), Parser.result(utils.isRelative) ], v => utils.combine(v[0], v[1])) + .add([ Parser.result(utils.isRelative), 'and', Parser.result(utils.isRelative) ], v => utils.combine(v[0], v[1])) + // nth day of week in month - .add([ ordinal, dayOfWeek, month ], v => { - const dayOfWeek = v[1].value; - const ordinal = v[0].value; - return withAdjuster(v[2], (date, now) => dayInMonth(now, date, ordinal, dayOfWeek)) - }) + .add([ ordinal, dayOfWeek, month ], v => utils.combine(v[2], { + dayOfWeek: v[1].value, + dayOfWeekOrdinal: v[0].value + })) // nth day of week in year - .add([ ordinal, dayOfWeek, year ], v => { - const dayOfWeek = v[1].value; - const ordinal = v[0].value; - return withAdjuster(v[2], (date, now) => dayInYear(now, date, ordinal, dayOfWeek)) - }) - + .add([ ordinal, dayOfWeek, year ], v => utils.combine(v[2], { + dayOfWeek: v[1].value, + dayOfWeekOrdinal: v[0].value + })) + + .add([ ordinal, dayOfWeek, Parser.result(utils.isRelative) ], v => utils.combine(v[2], { + dayOfWeek: v[1].value, + dayOfWeekOrdinal: v[0].value + })) .add([ 'in', Parser.result() ], v => v[0]) .add([ 'on', Parser.result() ], v => v[0]) diff --git a/language/en/datetime.js b/language/en/datetime.js new file mode 100644 index 0000000..9ed359a --- /dev/null +++ b/language/en/datetime.js @@ -0,0 +1,32 @@ +'use strict'; + +const Parser = require('../../parser'); +const utils = require('../dates'); + +module.exports = function(language) { + const time = language.time; + const date = language.date; + + return new Parser(language) + .name('datetime') + + .skipPunctuation() + + .add([ time, date ], v => utils.combine(v[0], v[1])) + .add([ time, 'and', date ], v => utils.combine(v[0], v[1])) + + .add([ date, time ], v => utils.combine(v[0], v[1])) + .add([ date, 'and', time ], v => utils.combine(v[0], v[1])) + + .add(Parser.result(date, utils.isRelative), (v, e) => { + const now = utils.currentTime(e); + return utils.combine(v[0], { + hour: now.getHours(), + minute: now.getMinutes() + }); + }) + .add(time, v => v[0]) + + .mapResults(utils.mapDateTime) + .onlyBest(); +}; diff --git a/language/en/month.js b/language/en/month.js index 0039ebb..d2ebf16 100644 --- a/language/en/month.js +++ b/language/en/month.js @@ -21,7 +21,7 @@ function adjustedMonth(date, diff) { } module.exports = function(language) { - const ordinal = language.ordinal; + const integer = language.integer; return new Parser(language) .name('month') @@ -74,7 +74,7 @@ module.exports = function(language) { .add('this month', (v, e) => adjustedMonth(currentTime(e), 0)) .add('last month', (v, e) => adjustedMonth(currentTime(e), -1)) .add('next month', (v, e) => adjustedMonth(currentTime(e), +1)) - .add([ 'in', ordinal, 'months' ], (v, e) => adjustedMonth(currentTime(e), v[0].value)) + .add([ 'in', integer, 'months' ], v => { return { relativeMonths: v[0].value }}) .add([ 'in', Parser.result() ], v => v[0]) diff --git a/language/en/time.js b/language/en/time.js index 8b8946b..12eab14 100644 --- a/language/en/time.js +++ b/language/en/time.js @@ -2,11 +2,7 @@ const Parser = require('../../parser'); const cloneDeep = require('lodash.clonedeep'); - -const addSeconds = require('date-fns/add_seconds') -const setHours = require('date-fns/set_hours') -const setMinutes = require('date-fns/set_minutes') -const setSeconds = require('date-fns/set_seconds') +const utils = require('../dates'); function value(v) { if(Array.isArray(v)) { @@ -180,35 +176,6 @@ module.exports = function(language) { .add([ Parser.result(isTime), 'sharp' ], v => combine(v[0], { precision: 'exact' })) - .mapResults((r, e) => { - const result = {}; - let time = currentTime(e); - - if(r.relative > 0) { - time = addSeconds(time, r.relative); - } else { - if(typeof r.hour !== 'undefined') { - time = setHours(time, r.hour); - } - - if(typeof r.minute !== 'undefined') { - time = setMinutes(time, r.minute); - } else { - time = setMinutes(time, 0); - } - - if(typeof r.second !== 'undefined') { - time = setSeconds(time, r.second); - } else { - time = setSeconds(time, 0); - } - } - - result.hour = time.getHours(); - result.minute = time.getMinutes(); - result.precision = r.precision || 'normal'; - - return result; - }) + .mapResults(utils.mapTime) .onlyBest(); } diff --git a/language/en/year.js b/language/en/year.js index 5e7223a..d06d0cc 100644 --- a/language/en/year.js +++ b/language/en/year.js @@ -12,7 +12,7 @@ module.exports = function(language) { .add('this year', (v, e) => { return { year: utils.currentTime(e).getFullYear() }}) .add('next year', (v, e) => { return { year: utils.currentTime(e).getFullYear() + 1 }}) .add('last year', (v, e) => { return { year: utils.currentTime(e).getFullYear() - 1 }}) - .add([ 'in', integer, 'years' ], (v, e) => { return { year: utils.currentTime(e).getFullYear() + v[0].value }}) + .add([ 'in', integer, 'years' ], v => { return { relativeYear: v[0].value }}) .add([ 'in', Parser.result() ], v => v[0]) .add([ 'of', Parser.result() ], v => v[0]) diff --git a/test/language.en.test.js b/test/language.en.test.js index 465c355..20ef254 100644 --- a/test/language.en.test.js +++ b/test/language.en.test.js @@ -13,6 +13,7 @@ const month = (text, options) => en.month.match(text, options); const year = (text, options) => en.year.match(text, options); const date = (text, options) => en.date.match(text, options); const time = (text, options) => en.time.match(text, options); +const datetime = (text, options) => en.datetime.match(text, options); describe('English', function() { describe('Tokenization', function() { @@ -468,7 +469,7 @@ describe('English', function() { it('in 4 years', function() { return year('in 4 years', { now: new Date(2010, 0, 1) }) .then(v => - expect(v).to.deep.equal({ year: 2014 }) + expect(v).to.deep.equal({ year: 2014, month: 0 }) ); }); }); @@ -705,6 +706,13 @@ describe('English', function() { ); }); + it('in 1 day', function() { + return date('in 1 day', { now: new Date(2010, 0, 1) }) + .then(v => + expect(v).to.deep.equal({ year: 2010, month: 0, day: 2 }) + ); + }); + it('in 3 days', function() { return date('in 3 days', { now: new Date(2010, 0, 1) }) .then(v => @@ -712,6 +720,27 @@ describe('English', function() { ); }); + it('in 2 months and 3 days', function() { + return date('in 2 months and 3 days', { now: new Date(2010, 0, 1) }) + .then(v => + expect(v).to.deep.equal({ year: 2010, month: 2, day: 4 }) + ); + }); + + it('in 1 week', function() { + return date('in 1 week', { now: new Date(2010, 0, 1) }) + .then(v => + expect(v).to.deep.equal({ year: 2010, month: 0, day: 8 }) + ); + }); + + it('in two weeks', function() { + return date('in 2 weeks', { now: new Date(2010, 0, 1) }) + .then(v => + expect(v).to.deep.equal({ year: 2010, month: 0, day: 15 }) + ); + }); + it('2nd this month', function() { return date('2nd this month', { now: new Date(2010, 0, 1) }) .then(v => @@ -1111,4 +1140,104 @@ describe('English', function() { }); }); }); + + describe('Date & Time', function() { + it('12:10, jan 12th', function() { + return datetime('12:10, jan 12th', { now: new Date(2010, 0, 1, 13, 30) }) + .then(v => + expect(v).to.deep.equal({ + year: 2010, + month: 0, + day: 12, + hour: 12, + minute: 10, + precision: 'normal' + }) + ); + }); + + it('jan 12th 12:10', function() { + return datetime('jan 12th 12:10', { now: new Date(2010, 0, 1, 13, 30) }) + .then(v => + expect(v).to.deep.equal({ + year: 2010, + month: 0, + day: 12, + hour: 12, + minute: 10, + precision: 'normal' + }) + ); + }); + + it('on jan 12th at 12:10', function() { + return datetime('on jan 12th at 12:10', { now: new Date(2010, 0, 1, 13, 30) }) + .then(v => + expect(v).to.deep.equal({ + year: 2010, + month: 0, + day: 12, + hour: 12, + minute: 10, + precision: 'normal' + }) + ); + }); + + it('14:15', function() { + return datetime('14:15', { now: new Date(2010, 0, 1, 13, 30) }) + .then(v => + expect(v).to.deep.equal({ + year: 2010, + month: 0, + day: 1, + hour: 14, + minute: 15, + precision: 'normal' + }) + ); + }); + + it('in 2 days', function() { + return datetime('in 2 days', { now: new Date(2010, 0, 1, 13, 30) }) + .then(v => + expect(v).to.deep.equal({ + year: 2010, + month: 0, + day: 3, + hour: 13, + minute: 30, + precision: 'normal' + }) + ); + }); + + it('in 2 days and 2 hours', function() { + return datetime('in 2 days and 2 hours', { now: new Date(2010, 0, 1, 13, 30) }) + .then(v => + expect(v).to.deep.equal({ + year: 2010, + month: 0, + day: 3, + hour: 15, + minute: 30, + precision: 'normal' + }) + ); + }); + + it('in 2 months and 2 days', function() { + return datetime('in 2 months and 2 days', { now: new Date(2010, 0, 1, 13, 30) }) + .then(v => + expect(v).to.deep.equal({ + year: 2010, + month: 2, + day: 3, + hour: 13, + minute: 30, + precision: 'normal' + }) + ); + }); + }); }); diff --git a/values/datetime.js b/values/datetime.js new file mode 100644 index 0000000..75149b3 --- /dev/null +++ b/values/datetime.js @@ -0,0 +1,7 @@ +'use strict'; + +const { LanguageSpecificValue, ParsingValue } = require('./index'); + +module.exports = function() { + return new LanguageSpecificValue(language => new ParsingValue(language.datetime)); +};