Skip to content

Commit

Permalink
fix(utils): parse relative dates with multiple time units (DIYgod#9365)
Browse files Browse the repository at this point in the history
* fix(utils): parse relative dates with multiple time units

* docs: remove warning

* fix: add more characters to match

* fix: rename to parse-date
  • Loading branch information
Ethan Shen authored and RikkaBlue committed Apr 9, 2022
1 parent bd456f7 commit 78dc9eb
Show file tree
Hide file tree
Showing 3 changed files with 317 additions and 24 deletions.
8 changes: 2 additions & 6 deletions docs/en/joinus/pub-date.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,11 @@ const pubDate = parseDate('2020/12/30', 'YYYY/MM/DD');

If you need to parse a relative date, use `parseRelativeDate`.

::: warning Warning
Only works for relative date in Chinese for now
:::

```javascript
const { parseRelativeDate } = require('@/utils/parse-date');

const pubDate = parseRelativeDate('2天前');
const pubDate = parseRelativeDate('前天 15:36');
const pubDate = parseRelativeDate('2 days ago');
const pubDate = parseRelativeDate('day before yesterday 15:36');
```

### Timezone
Expand Down
189 changes: 171 additions & 18 deletions lib/utils/parse-date.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,195 @@
const dayjs = require('dayjs');
dayjs.extend(require('dayjs/plugin/customParseFormat'));
dayjs.extend(require('dayjs/plugin/duration'));
dayjs.extend(require('dayjs/plugin/weekday'));

const words = [
{
startAt: dayjs(),
regExp: /^(?:(?:|)|t(?:o)?da(?:y)?)(.*)/,
},
{
startAt: dayjs().subtract(1, 'days'),
regExp: /^(?:(?:|)|y(?:ester)?da(?:y)?)(.*)/,
},
{
startAt: dayjs().subtract(2, 'days'),
regExp: /^(?:|(?:the)?d(?:ay)?b(?:eforeyesterda)?y)(.*)/,
},
{
startAt: dayjs().weekday(1),
regExp: /^(?:|)(.*)/,
},
{
startAt: dayjs().weekday(2),
regExp: /^(?:|)(.*)/,
},
{
startAt: dayjs().weekday(3),
regExp: /^(?:|)(.*)/,
},
{
startAt: dayjs().weekday(4),
regExp: /^(?:|)(.*)/,
},
{
startAt: dayjs().weekday(5),
regExp: /^(?:|)(.*)/,
},
{
startAt: dayjs().weekday(6),
regExp: /^(?:|)(.*)/,
},
{
startAt: dayjs().weekday(7),
regExp: /^(?:|)(?:|)(.*)/,
},
{
startAt: dayjs().add(1, 'days'),
regExp: /^(?:(?:|)|y(?:ester)?da(?:y)?)(.*)/,
},
{
startAt: dayjs().add(2, 'days'),
regExp: /^(?:(?:|)|(?:the)?d(?:ay)?b(?:eforeyesterda)?y)(.*)/,
},
];

const patterns = [
{
regexp: /^(\d+)$/,
handler: (minute) => dayjs().subtract(minute, 'minutes'),
unit: 'years',
regExp: /(\d+)(?:|y(?:ea)?r(?:s)?)/,
},
{
unit: 'months',
regExp: /(\d+)(?:(?:|)?|month(?:s)?)/,
},
{
regexp: /^(\d+)$/,
handler: (hour) => dayjs().subtract(hour, 'hours'),
unit: 'weeks',
regExp: /(\d+)(?:|(?:|)?|week(?:s)?)/,
},
{
regexp: /^(\d+)$/,
handler: (day) => dayjs().subtract(day, 'days'),
unit: 'days',
regExp: /(\d+)(?:||day(?:s)?)/,
},
{
regexp: /^\s*((\d+:\d+)?)$/,
handler: (hm) => dayjs(hm || '0:0', ['HH:m', 'HH:mm', 'H:m', 'H:mm']),
unit: 'hours',
regExp: /(\d+)(?:(?:|)?(?:(?:)?|||)|h(?:ou)?r(?:s)?)/,
},
{
regexp: /^\s*((\d+:\d+)?)$/,
handler: (hm) => dayjs(hm || '0:0', ['HH:m', 'HH:mm', 'H:m', 'H:mm']).subtract(1, 'day'),
unit: 'minutes',
regExp: /(\d+)(?:(?:|)?|min(?:ute)?(?:s)?)/,
},
{
regexp: /^\s*((\d+:\d+)?)$/,
handler: (hm) => dayjs(hm || '0:0', ['HH:m', 'HH:mm', 'H:m', 'H:mm']).subtract(2, 'day'),
unit: 'seconds',
regExp: /(\d+)(?:(?:|)?|sec(?:ond)?(?:s)?)/,
},
];

const patternSize = Object.keys(patterns).length;

/**
* 预处理日期字符串
* @param {String} date 原始日期字符串
*/
const toDate = (date) =>
date
.toLowerCase()
.replace(/(^a(?:n)?\s)|(\sa(?:n)?\s)/g, '1') // 替换 `a` 和 `an` 为 `1`
.replace(/|/g, '3') // 如 `几秒钟前` 视作 `3秒钟前`
.replace(/[\s,]/g, ''); // 移除所有空格

/**
* 将 `['\d+时', ..., '\d+秒']` 转换为 `{ hours: \d+, ..., seconds: \d+ }`
* 用于描述时间长度
* @param {Array.<String>} matches 所有匹配结果
*/
const toDurations = (matches) => {
const durations = {};

let p = 0;
for (const m of matches) {
for (; p <= patternSize; p++) {
const match = patterns[p].regExp.exec(m);
if (match) {
durations[patterns[p].unit] = match[1];
break;
}
}
}
return durations;
};

module.exports = {
parseDate: (date, ...options) => dayjs(date, ...options).toDate(),
parseRelativeDate: (date) => {
for (const pattern of patterns) {
const match = pattern.regexp.exec(date);
if (match !== null) {
return pattern.handler(match[1]).toDate();
// 预处理日期字符串 date

const theDate = toDate(date);

// 将 `\d+年\d+月...\d+秒前` 分割成 `['\d+年', ..., '\d+秒前']`

const matches = theDate.match(/(?:\D+)?\d+(?!:|-|\/)\D+/g);

if (matches) {
// 获得最后的时间单元,如 `\d+秒前`

const lastMatch = matches.pop();

// 若最后的时间单元含有 `前`、`以前`、`之前` 等标识字段,减去相应的时间长度
// 如 `1分10秒前`

const beforeMatches = /(.*)(?:(?:|)?|ago)$/.exec(lastMatch);
if (beforeMatches) {
matches.push(beforeMatches[1]);
return dayjs()
.subtract(dayjs.duration(toDurations(matches)))
.toDate();
}

// 若最后的时间单元含有 `后`、`以后`、`之后` 等标识字段,加上相应的时间长度
// 如 `1分10秒后`

const afterMatches = /(?:^in(.*)|(.*)(?:|)?(?:|))$/.exec(lastMatch);
if (afterMatches) {
matches.push(afterMatches[1] ?? afterMatches[2]);
return dayjs()
.add(dayjs.duration(toDurations(matches)))
.toDate();
}

// 以下处理日期字符串 date 含有特殊词的情形
// 如 `今天1点10分`

matches.push(lastMatch);
const firstMatch = matches.shift();

for (const w of words) {
const wordMatches = w.regExp.exec(firstMatch);
if (wordMatches) {
matches.unshift(wordMatches[1]);

// 取特殊词对应日零时为起点,加上相应的时间长度

return w.startAt
.set('hour', 0)
.set('minute', 0)
.set('second', 0)
.set('millisecond', 0)
.add(dayjs.duration(toDurations(matches)))
.toDate();
}
}
} else {
// 若日期字符串 date 不匹配 patterns 中所有模式,则默认为 `特殊词 + 标准时间格式` 的情形,此时直接将特殊词替换为对应日期
// 如今天为 `2022-03-22`,则 `今天 20:00` => `2022-03-22 20:00`

for (const w of words) {
const wordMatches = w.regExp.exec(theDate);
if (wordMatches) {
return dayjs(`${w.startAt.format('YYYY-MM-DD')} ${wordMatches[1]}`).toDate();
}
}
}
return null;

return date;
},
};
144 changes: 144 additions & 0 deletions test/utils/parse-date.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
const { parseRelativeDate } = require('@/utils/parse-date');
const MockDate = require('mockdate');

describe('parseRelativeDate', () => {
const date = new Date();

MockDate.set(date);

it('s秒钟前', () => {
expect(+new Date(parseRelativeDate('10秒前'))).toBe(+date - 10 * 1000);
});

it('m分钟前', () => {
expect(+new Date(parseRelativeDate('10分钟前'))).toBe(+date - 10 * 60 * 1000);
});

it('m分鐘前', () => {
expect(+new Date(parseRelativeDate('10分鐘前'))).toBe(+date - 10 * 60 * 1000);
});

it('m分钟后', () => {
expect(+new Date(parseRelativeDate('10分钟后'))).toBe(+date + 10 * 60 * 1000);
});

it('a minute ago', () => {
expect(+new Date(parseRelativeDate('a minute ago'))).toBe(+date - 1 * 60 * 1000);
});

it('s minutes ago', () => {
expect(+new Date(parseRelativeDate('10 minutes ago'))).toBe(+date - 10 * 60 * 1000);
});

it('s mins ago', () => {
expect(+new Date(parseRelativeDate('10 mins ago'))).toBe(+date - 10 * 60 * 1000);
});

it('in s minutes', () => {
expect(+new Date(parseRelativeDate('in 10 minutes'))).toBe(+date + 10 * 60 * 1000);
});

it('in an hour', () => {
expect(+new Date(parseRelativeDate('in an hour'))).toBe(+date + 1 * 60 * 60 * 1000);
});

it('H小时前', () => {
expect(+new Date(parseRelativeDate('10小时前'))).toBe(+date - 10 * 60 * 60 * 1000);
});

it('H个小时前', () => {
expect(+new Date(parseRelativeDate('10个小时前'))).toBe(+date - 10 * 60 * 60 * 1000);
});

it('D天前', () => {
expect(+new Date(parseRelativeDate('10天前'))).toBe(+date - 10 * 24 * 60 * 60 * 1000);
});

it('W周前', () => {
expect(+new Date(parseRelativeDate('10周前'))).toBe(+date - 10 * 7 * 24 * 60 * 60 * 1000);
});

it('W星期前', () => {
expect(+new Date(parseRelativeDate('10星期前'))).toBe(+date - 10 * 7 * 24 * 60 * 60 * 1000);
});

it('W个星期前', () => {
expect(+new Date(parseRelativeDate('10个星期前'))).toBe(+date - 10 * 7 * 24 * 60 * 60 * 1000);
});

it('M月前', () => {
expect(+new Date(parseRelativeDate('1月前'))).toBe(+date - 1 * 30 * 24 * 60 * 60 * 1000);
});

it('M个月前', () => {
expect(+new Date(parseRelativeDate('1个月前'))).toBe(+date - 1 * 30 * 24 * 60 * 60 * 1000);
});

it('Y年前', () => {
expect(+new Date(parseRelativeDate('1年前'))).toBe(+date - 1 * 365 * 24 * 60 * 60 * 1000);
});

it('Y年M个月前', () => {
expect(+new Date(parseRelativeDate('1年1个月前'))).toBe(+date - 1 * 365 * 24 * 60 * 60 * 1000 - 1 * 30 * 24 * 60 * 60 * 1000);
});

it('D天H小时前', () => {
expect(+new Date(parseRelativeDate('1天1小时前'))).toBe(+date - 1 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000);
});

it('H小时m分钟s秒钟前', () => {
expect(+new Date(parseRelativeDate('1小时1分钟1秒钟前'))).toBe(+date - 1 * 60 * 60 * 1000 - 1 * 60 * 1000 - 1 * 1000);
});

it('H小时m分钟s秒钟后', () => {
expect(+new Date(parseRelativeDate('1小时1分钟1秒钟后'))).toBe(+date + 1 * 60 * 60 * 1000 + 1 * 60 * 1000 + 1 * 1000);
});

it('今天', () => {
expect(+new Date(parseRelativeDate('今天'))).toBe(+date.setHours(0, 0, 0, 0));
});

it('Today H:m', () => {
expect(+new Date(parseRelativeDate('Today 08:00'))).toBe(+date + 8 * 60 * 60 * 1000);
});

it('TDA H:m:s', () => {
expect(+new Date(parseRelativeDate('TDA 08:00:00'))).toBe(+date + 8 * 60 * 60 * 1000);
});

it('今天 H:m', () => {
expect(+new Date(parseRelativeDate('今天 08:00'))).toBe(+date + 8 * 60 * 60 * 1000);
});

it('今天H点m分', () => {
expect(+new Date(parseRelativeDate('今天8点0分'))).toBe(+date + 8 * 60 * 60 * 1000);
});

it('昨日H点m分s秒', () => {
expect(+new Date(parseRelativeDate('昨日20时0分0秒'))).toBe(+date - 4 * 60 * 60 * 1000);
});

it('前天 H:m', () => {
expect(+new Date(parseRelativeDate('前天 20:00'))).toBe(+date - 28 * 60 * 60 * 1000);
});

it('明天 H:m', () => {
expect(+new Date(parseRelativeDate('明天 20:00'))).toBe(+date + 44 * 60 * 60 * 1000);
});

it('星期几 h:m', () => {
expect(+new Date(parseRelativeDate('星期一 8:00'))).toBe(+new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1 - (date.getDay() || 7)) + 8 * 60 * 60 * 1000);
});

it('周几 h:m', () => {
expect(+new Date(parseRelativeDate('周二 8:00'))).toBe(+new Date(date.getFullYear(), date.getMonth(), date.getDate() + 2 - (date.getDay() || 7)) + 8 * 60 * 60 * 1000);
});

it('星期天 h:m', () => {
expect(+new Date(parseRelativeDate('星期天 8:00'))).toBe(+new Date(date.getFullYear(), date.getMonth(), date.getDate() + 7 - (date.getDay() || 7)) + 8 * 60 * 60 * 1000);
});

it('Invalid', () => {
expect(parseRelativeDate('RSSHub')).toBe('RSSHub');
});
});

0 comments on commit 78dc9eb

Please sign in to comment.