diff --git a/plugins/dates/src/api/parse/one/01-tokenize/01-shift.js b/plugins/dates/src/api/parse/one/01-tokenize/01-shift.js new file mode 100644 index 0000000..0aa8488 --- /dev/null +++ b/plugins/dates/src/api/parse/one/01-tokenize/01-shift.js @@ -0,0 +1,128 @@ +const knownUnits = { + second: true, + minute: true, + hour: true, + day: true, + week: true, + weekend: true, + month: true, + season: true, + quarter: true, + year: true, +} + +const aliases = { + wk: 'week', + min: 'minute', + sec: 'second', + weekend: 'week', //for now... +} + +// half of a unit, in smaller whole units +const halfUnits = { + year: [6, 'month'], + quarter: [45, 'day'], + season: [45, 'day'], + month: [15, 'day'], + week: [84, 'hour'], + day: [12, 'hour'], + hour: [30, 'minute'], + minute: [30, 'second'], +} + +const parseUnit = function (m) { + let unit = m.match('#Duration').text('normal') + unit = unit.replace(/s$/, '') + // support shorthands like 'min' + if (Object.hasOwn(aliases, unit)) { + unit = aliases[unit] + } + return unit +} + +//turn '5 weeks before' to {weeks:5} +const parseShift = function (doc) { + const result = {} + let m = doc.none() + let shift = doc.match('#DateShift+') + if (shift.found === false) { + return { result, m } + } + //is it 2 weeks ago? → -2 ('hence' means the future) + const isNegative = shift.has('(before|ago|back)$') === true + + // '5 weeks' + shift.match('#Cardinal #Duration').forEach((ts) => { + const num = ts.match('#Cardinal').numbers().get()[0] + if (num && typeof num === 'number') { + const unit = parseUnit(ts) + if (knownUnits[unit] === true) { + result[unit] = isNegative ? num * -1 : num + } + } + }) + m = shift.match('#Cardinal #Duration') + shift = shift.not(m) + + // supoprt '1 day after tomorrow' + m = shift.match('[#Duration] [(after|before)]') + if (m.found) { + const unit = m.groups('unit').text('reduced') + // unit = unit.replace(/s$/, '') + const dir = m.groups('dir').text('reduced') + if (dir === 'after') { + result[unit] = 1 + } else if (dir === 'before') { + result[unit] = -1 + } + } + + // in half an hour + m = shift.match('half (a|an) [#Duration]', 0) + if (m.found) { + const unit = parseUnit(m) + if (knownUnits[unit] === true) { + result[unit] = isNegative ? -0.5 : 0.5 + } + } + + // a few years / a couple of weeks + m = shift.match('a [(few|couple)] of? [#Duration]') + if (m.found) { + const unit = parseUnit(m.groups('unit')) + if (knownUnits[unit] === true) { + const num = m.groups('amt').has('few') ? 3 : 2 + result[unit] = isNegative ? num * -1 : num + } + } + + // '2 weeks and a half' + m = doc.match('[#Duration] and a half') + if (m.found) { + const unit = parseUnit(m.groups('unit')) + if (knownUnits[unit] === true && result[unit] !== undefined) { + result[unit] += isNegative ? -0.5 : 0.5 + } + } + + // spacetime drops fractional units - swap a half-unit for smaller whole units + Object.keys(result).forEach((k) => { + const whole = Math.trunc(result[k]) + const frac = result[k] - whole + if (frac === 0.5 || frac === -0.5) { + const [num, smaller] = halfUnits[k] || [] + if (smaller) { + result[k] = whole + result[smaller] = (result[smaller] || 0) + (frac > 0 ? num : num * -1) + if (result[k] === 0) { + delete result[k] + } + } + } + }) + + // finally, remove it from our text + m = doc.match('#DateShift+') + return { result, m } +} +export default parseShift diff --git a/plugins/dates/tests/regressions.test.js b/plugins/dates/tests/regressions.test.js new file mode 100644 index 0000000..13fec18 --- /dev/null +++ b/plugins/dates/tests/regressions.test.js @@ -0,0 +1,169 @@ +import test from 'tape' +import nlp from './_lib.js' + +const context = { + today: '2026-07-11', //saturday + timezone: 'America/New_York', +} +const getStart = (str, ctx = context) => { + const res = nlp(str).dates(ctx).get()[0] + return res ? res.start : null +} +const getEnd = (str, ctx = context) => { + const res = nlp(str).dates(ctx).get()[0] + return res ? res.end : null +} + +test('holidays honor an explicit year', (t) => { + // easter 2026 already happened - but the year is explicit + t.equal(getStart('easter 2026'), '2026-04-05T00:00:00.000-04:00', 'easter 2026') + t.equal(getStart('easter 2030'), '2030-04-21T00:00:00.000-04:00', 'easter 2030') + t.equal(getStart('thanksgiving 2021'), '2021-11-25T00:00:00.000-05:00', 'thanksgiving 2021') + // asking on the day itself, in the afternoon + const xmas = { today: '2026-12-25T13:00:00', timezone: 'America/New_York' } + t.equal(getStart('christmas', xmas), '2026-12-25T00:00:00.000-05:00', 'christmas on christmas-day') + t.end() +}) + +test('overnight time-ranges cross midnight', (t) => { + t.equal(getStart('from 10pm to 2am'), '2026-07-11T22:00:00.000-04:00', '10pm-2am start') + t.equal(getEnd('from 10pm to 2am'), '2026-07-12T02:00:00.000-04:00', '10pm-2am end') + // explicit 'am' should not become pm + t.equal(getStart('11pm to 2am january 5th'), '2027-01-05T23:00:00.000-05:00', 'explicit-am start') + t.equal(getEnd('11pm to 2am january 5th'), '2027-01-06T02:00:00.000-05:00', 'explicit-am end') + t.end() +}) + +test('between-ranges are forward-ordered', (t) => { + // was: a reversed range with negative duration + t.equal(getStart('between friday and sunday'), '2026-07-17T00:00:00.000-04:00', 'starts friday') + t.equal(getEnd('between friday and sunday'), '2026-07-18T23:59:59.999-04:00', 'ends before sunday') + // 'between june 2nd and 5th' - inherit the month + t.equal(getStart('between june 2nd and 5th'), '2027-06-02T00:00:00.000-04:00', 'shared-month start') + t.equal(getEnd('between june 2nd and 5th'), '2027-06-04T23:59:59.999-04:00', 'shared-month end') + t.end() +}) + +test('end of the month', (t) => { + t.equal(getStart('end of the month'), '2026-07-31T23:59:59.999-04:00', 'end of the month') + t.equal(getStart('start of the month'), '2026-07-01T00:00:00.000-04:00', 'start of the month') + t.equal(getStart('end of the year'), '2026-12-31T23:59:59.999-05:00', 'end of the year') + t.end() +}) + +test('day after next', (t) => { + t.equal(getStart('day after next'), '2026-07-13T00:00:00.000-04:00', 'day after next') + t.equal(getStart('the week after next'), '2026-07-20T00:00:00.000-04:00', 'week after next') + t.equal(getStart('the weekend after next'), '2026-07-25T00:00:00.000-04:00', 'weekend after next') + const wed = { today: '2026-07-08', timezone: 'America/New_York' } + t.equal(getStart('the saturday after next', wed), '2026-07-18T00:00:00.000-04:00', 'saturday after next') + t.end() +}) + +test('next-month wraps the year', (t) => { + const dec = { today: '2026-12-10', timezone: 'America/New_York' } + t.equal(getStart('the 5th of next month', dec), '2027-01-05T00:00:00.000-05:00', 'next month in december') + const jan = { today: '2026-01-10', timezone: 'America/New_York' } + t.equal(getStart('the 5th of last month', jan), '2025-12-05T00:00:00.000-05:00', 'last month in january') + t.end() +}) + +test('relative shifts', (t) => { + t.equal(getStart('two weeks hence'), '2026-07-25T00:00:00.000-04:00', 'hence is the future') + t.equal(getStart('in a few weeks'), '2026-08-01T00:00:00.000-04:00', 'a few is 3') + t.equal(getStart('in a couple of weeks'), '2026-07-25T00:00:00.000-04:00', 'a couple of') + t.equal(getStart('a few days ago'), '2026-07-08T00:00:00.000-04:00', 'a few days ago') + const noon = { today: '2026-07-11T12:00:00', timezone: 'America/New_York' } + t.equal(getStart('in half an hour', noon), '2026-07-11T12:30:00.000-04:00', 'half an hour is 30min') + t.equal(getStart('half an hour ago', noon), '2026-07-11T11:30:00.000-04:00', 'half an hour ago') + t.equal(getStart('2 years, 4 months, and 5 days ago'), '2024-03-06T00:00:00.000-05:00', 'multi-unit shift') + t.equal(getStart('a week and a half before'), '2026-06-30T12:00:00.000-04:00', 'a week and a half') + t.equal(getStart('in a year and a half'), '2028-01-11T00:00:00.000-05:00', 'in a year and a half') + t.end() +}) + +test('nth weekday of month', (t) => { + const ctx = { today: '2021-01-01', timezone: 'America/New_York' } + t.equal(getStart('the second monday of february', ctx), '2021-02-08T00:00:00.000-05:00', '2nd monday') + t.equal(getStart('first monday of february', ctx), '2021-02-01T00:00:00.000-05:00', 'first monday') + t.equal(getStart('last monday of february', ctx), '2021-02-22T00:00:00.000-05:00', 'last monday') + const jan = { today: '2026-01-11', timezone: 'America/New_York' } + t.equal(getStart('2nd weekend of june', jan), '2026-06-13T00:00:00.000-04:00', '2nd weekend') + t.end() +}) + +test('march/may as verbs', (t) => { + t.equal(getStart('the soldiers march tomorrow'), '2026-07-12T00:00:00.000-04:00', 'march tomorrow → tomorrow') + t.equal(getStart('you may tomorrow find peace'), '2026-07-12T00:00:00.000-04:00', 'may tomorrow → tomorrow') + t.equal(getStart('may 2020'), '2020-05-01T00:00:00.000-04:00', 'may 2020 still works') + t.equal(getStart('march 5th'), '2027-03-05T00:00:00.000-05:00', 'march 5th still works') + t.end() +}) + +test('quarter-to times', (t) => { + t.equal(getStart('quarter to five'), '2026-07-11T16:45:00.000-04:00', 'quarter to five') + t.equal(getStart('at quarter to five'), '2026-07-11T16:45:00.000-04:00', 'at quarter to five') + t.equal(getStart('at ten to 4'), '2026-07-11T15:50:00.000-04:00', 'at ten to 4') + t.end() +}) + +test('year windows', (t) => { + t.equal(getStart('2030'), '2030-01-01T00:00:00.000-05:00', 'bare 2030') + t.equal(getStart('2045'), '2045-01-01T00:00:00.000-05:00', 'bare 2045') + t.equal(getStart(`june of '98`), '1998-06-01T00:00:00.000-04:00', `june of '98`) + t.equal(getStart(`summer of '69`), '1969-06-01T00:00:00.000-04:00', `summer of '69`) + t.equal(getStart('may 97'), '1997-05-01T00:00:00.000-04:00', 'may 97') + t.end() +}) + +test('timezone handling', (t) => { + t.equal(getStart('4pm JST'), '2026-07-11T16:00:00.000+09:00', 'jst resolves') + t.equal(getStart('4pm SGT'), '2026-07-11T16:00:00.000+08:00', 'sgt resolves') + t.equal(getStart('4pm GMT+9'), '2026-07-11T16:00:00.000+09:00', 'gmt+9 is utc+9') + t.equal(getStart('3pm utc-5'), '2026-07-11T15:00:00.000-05:00', 'utc-5') + t.equal(getStart('5pm utc+13'), '2026-07-11T17:00:00.000+13:00', 'utc+13') + // an unknown zone should not kill the parse + t.equal(getStart('4pm XQZT'), '2026-07-11T16:00:00.000-04:00', 'unknown zone is non-fatal') + t.equal(getStart('3pm cst'), '2026-07-11T15:00:00.000-05:00', 'cst is chicago') + t.equal(getStart('3pm central time'), '2026-07-11T15:00:00.000-05:00', 'central time') + const notMidnight = { today: '2026-07-11T15:00:00', timezone: 'America/New_York' } + t.equal(getStart('now pst', notMidnight), '2026-07-11T15:00:00.000-07:00', 'now pst keeps the time') + t.end() +}) + +test('repeating dates surface in json', (t) => { + const res = nlp('every tuesday').dates(context).get() + t.equal(res.length, 1, 'every tuesday found') + t.ok(res[0].repeat, 'has repeat info') + t.ok(res[0].repeat.filter.weekDays.tuesday, 'tuesday filter') + const july = nlp('weekends in july').dates(context).get()[0] + t.ok(july.repeat && july.repeat.filter.weekDays.saturday, 'weekends in july') + t.equal(july.start, '2026-07-01T00:00:00.000-04:00', 'july range start') + t.end() +}) + +test('unit field', (t) => { + const res = nlp('june 9th 2012').dates(context).get()[0] + t.equal(res.unit, 'day', 'june 9th is a day') + const range = nlp('jan 1 to dec 31').dates(context).get()[0] + t.equal(range.unit, 'year', 'jan 1 to dec 31 is a year') + t.end() +}) + +test('times api shape', (t) => { + t.deepEqual(nlp('hello world').times().get(), [], 'empty is an array') + const one = nlp('at 4:30pm').times().get(0) + t.equal(one.time, '4:30pm', 'get(0) is an object') + t.end() +}) + +test('eod is end-of-day', (t) => { + t.equal(getStart('due eod'), '2026-07-11T22:00:00.000-04:00', 'due eod') + t.end() +}) + +test('a range does not stop other dates from splitting', (t) => { + const res = nlp('it runs between june and july. see me june 5, june 10').dates(context).get() + t.equal(res.length, 3, 'three dates found') + t.end() +})