ko-chrono 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -66
- package/dist/cjs/event.d.ts +20 -0
- package/dist/cjs/event.js +33 -0
- package/dist/cjs/index.d.ts +55 -0
- package/dist/cjs/index.js +192 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/parsers/absoluteDate.d.ts +3 -0
- package/dist/cjs/parsers/absoluteDate.js +95 -0
- package/dist/cjs/parsers/duration.d.ts +3 -0
- package/dist/cjs/parsers/duration.js +45 -0
- package/dist/cjs/parsers/holiday.d.ts +3 -0
- package/dist/cjs/parsers/holiday.js +72 -0
- package/dist/cjs/parsers/offset.d.ts +3 -0
- package/dist/cjs/parsers/offset.js +64 -0
- package/dist/cjs/parsers/recurrence.d.ts +5 -0
- package/dist/cjs/parsers/recurrence.js +117 -0
- package/dist/cjs/parsers/relativeDay.d.ts +3 -0
- package/dist/cjs/parsers/relativeDay.js +26 -0
- package/dist/cjs/parsers/relativeMonth.d.ts +3 -0
- package/dist/cjs/parsers/relativeMonth.js +30 -0
- package/dist/cjs/parsers/relativeYear.d.ts +3 -0
- package/dist/cjs/parsers/relativeYear.js +30 -0
- package/dist/cjs/parsers/time.d.ts +3 -0
- package/dist/cjs/parsers/time.js +152 -0
- package/dist/cjs/parsers/weekday.d.ts +3 -0
- package/dist/cjs/parsers/weekday.js +109 -0
- package/dist/cjs/resolve.d.ts +21 -0
- package/dist/cjs/resolve.js +446 -0
- package/dist/cjs/types.d.ts +125 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/util/lunarHolidays.d.ts +11 -0
- package/dist/cjs/util/lunarHolidays.js +82 -0
- package/dist/cjs/util/number.d.ts +5 -0
- package/dist/cjs/util/number.js +42 -0
- package/dist/cjs/util/timezone.d.ts +4 -0
- package/dist/cjs/util/timezone.js +55 -0
- package/dist/event.d.ts +20 -0
- package/dist/event.js +30 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +54 -6
- package/dist/parsers/absoluteDate.js +6 -2
- package/dist/parsers/duration.js +4 -2
- package/dist/parsers/holiday.d.ts +3 -0
- package/dist/parsers/holiday.js +68 -0
- package/dist/parsers/offset.js +6 -3
- package/dist/parsers/recurrence.d.ts +5 -0
- package/dist/parsers/recurrence.js +112 -0
- package/dist/parsers/time.js +22 -2
- package/dist/parsers/weekday.js +10 -0
- package/dist/resolve.d.ts +2 -2
- package/dist/resolve.js +164 -13
- package/dist/types.d.ts +53 -0
- package/dist/util/lunarHolidays.d.ts +11 -0
- package/dist/util/lunarHolidays.js +76 -0
- package/dist/util/timezone.d.ts +4 -0
- package/dist/util/timezone.js +51 -0
- package/package.json +13 -5
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseKoreanNumber = parseKoreanNumber;
|
|
4
|
+
const NATIVE = {
|
|
5
|
+
한: 1, 하나: 1, 두: 2, 둘: 2, 세: 3, 셋: 3, 네: 4, 넷: 4,
|
|
6
|
+
다섯: 5, 여섯: 6, 일곱: 7, 여덟: 8, 아홉: 9, 열: 10,
|
|
7
|
+
열흘: 10, 보름: 15, 스무: 20, 스물: 20,
|
|
8
|
+
이틀: 2, 사흘: 3, 나흘: 4, 닷새: 5, 엿새: 6, 이레: 7, 여드레: 8, 아흐레: 9,
|
|
9
|
+
};
|
|
10
|
+
const SINO = {
|
|
11
|
+
일: 1, 이: 2, 삼: 3, 사: 4, 오: 5, 육: 6, 칠: 7, 팔: 8, 구: 9, 십: 10,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* "3", "세", "열", "십이", "열두" → number. 실패 시 null.
|
|
15
|
+
* v0.1: 1~99 범위만 다룬다.
|
|
16
|
+
*/
|
|
17
|
+
function parseKoreanNumber(s) {
|
|
18
|
+
if (/^\d+$/.test(s))
|
|
19
|
+
return parseInt(s, 10);
|
|
20
|
+
if (s in NATIVE)
|
|
21
|
+
return NATIVE[s];
|
|
22
|
+
// 고유어 합성: 열두, 열다섯, 스무 ...
|
|
23
|
+
for (const tens of ['스물', '스무', '열']) {
|
|
24
|
+
if (s.startsWith(tens) && s.length > tens.length) {
|
|
25
|
+
const rest = NATIVE[s.slice(tens.length)];
|
|
26
|
+
if (rest != null)
|
|
27
|
+
return NATIVE[tens] + rest;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// 한자어: 십, 십이, 이십, 이십삼
|
|
31
|
+
if (/^[일이삼사오육칠팔구십]+$/.test(s)) {
|
|
32
|
+
const parts = s.split('십');
|
|
33
|
+
if (parts.length === 1)
|
|
34
|
+
return SINO[s] ?? null;
|
|
35
|
+
const tens = parts[0] === '' ? 1 : SINO[parts[0]];
|
|
36
|
+
const ones = parts[1] === '' ? 0 : SINO[parts[1]];
|
|
37
|
+
if (tens == null || ones == null)
|
|
38
|
+
return null;
|
|
39
|
+
return tens * 10 + ones;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// IANA 시간대 변환. 런타임 의존성 없이 Intl만 쓴다.
|
|
3
|
+
//
|
|
4
|
+
// resolve.ts는 Date의 로컬 getter/setter로 계산한다. timezone 옵션이 있으면
|
|
5
|
+
// 기준 시각을 그 시간대의 벽시계 시각(로컬 필드가 같은 Date)으로 바꿔 계산하고,
|
|
6
|
+
// 결과의 벽시계 시각을 다시 실제 시점으로 되돌린다.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.toWallClock = toWallClock;
|
|
9
|
+
exports.fromWallClock = fromWallClock;
|
|
10
|
+
const formatters = new Map();
|
|
11
|
+
function formatter(timeZone) {
|
|
12
|
+
let f = formatters.get(timeZone);
|
|
13
|
+
if (!f) {
|
|
14
|
+
// 없는 시간대면 여기서 RangeError
|
|
15
|
+
f = new Intl.DateTimeFormat('en-US', {
|
|
16
|
+
timeZone,
|
|
17
|
+
hourCycle: 'h23',
|
|
18
|
+
year: 'numeric',
|
|
19
|
+
month: 'numeric',
|
|
20
|
+
day: 'numeric',
|
|
21
|
+
hour: 'numeric',
|
|
22
|
+
minute: 'numeric',
|
|
23
|
+
second: 'numeric',
|
|
24
|
+
});
|
|
25
|
+
formatters.set(timeZone, f);
|
|
26
|
+
}
|
|
27
|
+
return f;
|
|
28
|
+
}
|
|
29
|
+
/** 시점 t에서 그 시간대의 벽시계 필드 */
|
|
30
|
+
function fieldsAt(t, timeZone) {
|
|
31
|
+
const parts = {};
|
|
32
|
+
for (const p of formatter(timeZone).formatToParts(new Date(t))) {
|
|
33
|
+
if (p.type !== 'literal')
|
|
34
|
+
parts[p.type] = +p.value;
|
|
35
|
+
}
|
|
36
|
+
return [parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second];
|
|
37
|
+
}
|
|
38
|
+
/** 시점 t에서 그 시간대의 UTC 오프셋(ms). 서울 = +9h */
|
|
39
|
+
function offsetAt(t, timeZone) {
|
|
40
|
+
const [y, m0, d, h, mi, s] = fieldsAt(t, timeZone);
|
|
41
|
+
return Date.UTC(y, m0, d, h, mi, s) - (t - (t % 1000));
|
|
42
|
+
}
|
|
43
|
+
/** 실제 시점 → 그 시간대의 벽시계 시각을 로컬 필드로 갖는 Date */
|
|
44
|
+
function toWallClock(instant, timeZone) {
|
|
45
|
+
const [y, m0, d, h, mi, s] = fieldsAt(instant.getTime(), timeZone);
|
|
46
|
+
return new Date(y, m0, d, h, mi, s, instant.getMilliseconds());
|
|
47
|
+
}
|
|
48
|
+
/** 로컬 필드를 그 시간대의 벽시계 시각으로 보고 → 실제 시점 */
|
|
49
|
+
function fromWallClock(wall, timeZone) {
|
|
50
|
+
const asUtc = Date.UTC(wall.getFullYear(), wall.getMonth(), wall.getDate(), wall.getHours(), wall.getMinutes(), wall.getSeconds(), wall.getMilliseconds());
|
|
51
|
+
// 오프셋은 시점에 따라 달라지므로(서머타임) 한 번 더 맞춘다
|
|
52
|
+
const first = offsetAt(asUtc, timeZone);
|
|
53
|
+
const second = offsetAt(asUtc - first, timeZone);
|
|
54
|
+
return new Date(asUtc - second);
|
|
55
|
+
}
|
package/dist/event.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Ambiguity, ParseResult, Recurrence } from './types.js';
|
|
2
|
+
/** parseEvent()의 결과: 할 일·일정 한 줄을 제목과 시점으로 나눈 것 */
|
|
3
|
+
export interface ParsedEvent {
|
|
4
|
+
/** 날짜 부분을 뺀 나머지 ("내일 3시 치과" → "치과"). 없으면 '' */
|
|
5
|
+
title: string;
|
|
6
|
+
/** 첫 번째 날짜 표현의 date. 날짜가 없으면 null */
|
|
7
|
+
date: Date | null;
|
|
8
|
+
/** 구간의 끝이나 마감 */
|
|
9
|
+
end?: Date;
|
|
10
|
+
hasTime: boolean;
|
|
11
|
+
/** 반복 일정이면 그 규칙 */
|
|
12
|
+
recurrence?: Recurrence;
|
|
13
|
+
/** 해석이 갈릴 수 있으면 그 이유와 다른 후보 ("3시" → 15시, 후보 3시) */
|
|
14
|
+
ambiguity?: Ambiguity;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
|
|
18
|
+
* 장소("강남역에서")나 동사("만나기")는 그대로 남긴다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function toEvent(text: string, results: ParseResult[]): ParsedEvent;
|
package/dist/event.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 날짜 바로 뒤에 붙은 조사: "3시에", "모레까지는", "주말엔"
|
|
2
|
+
const TRAILING_PARTICLE = /^(?:에는|에도|에|엔|은|는|도|까지는|까지|부터|쯤|께|경)(?=[\s,.!?]|$)/;
|
|
3
|
+
/**
|
|
4
|
+
* 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
|
|
5
|
+
* 장소("강남역에서")나 동사("만나기")는 그대로 남긴다.
|
|
6
|
+
*/
|
|
7
|
+
export function toEvent(text, results) {
|
|
8
|
+
let rest = text;
|
|
9
|
+
// 뒤에서부터 지워야 앞쪽 index가 어긋나지 않는다
|
|
10
|
+
for (const r of [...results].sort((a, b) => b.index - a.index)) {
|
|
11
|
+
const after = rest.slice(r.index + r.text.length);
|
|
12
|
+
const particle = TRAILING_PARTICLE.exec(after)?.[0] ?? '';
|
|
13
|
+
rest = rest.slice(0, r.index) + ' ' + after.slice(particle.length);
|
|
14
|
+
}
|
|
15
|
+
const title = rest
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.replace(/\s+([,.!?])/g, '$1')
|
|
18
|
+
.replace(/^[\s,]+|[\s,]+$/g, '');
|
|
19
|
+
const first = results[0];
|
|
20
|
+
if (!first)
|
|
21
|
+
return { title, date: null, hasTime: false };
|
|
22
|
+
const event = { title, date: first.date, hasTime: first.hasTime };
|
|
23
|
+
if (first.end)
|
|
24
|
+
event.end = first.end;
|
|
25
|
+
if (first.recurrence)
|
|
26
|
+
event.recurrence = first.recurrence;
|
|
27
|
+
if (first.ambiguity)
|
|
28
|
+
event.ambiguity = first.ambiguity;
|
|
29
|
+
return event;
|
|
30
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ParseOptions, ParseResult } from './types.js';
|
|
2
|
-
|
|
2
|
+
import { type ParsedEvent } from './event.js';
|
|
3
|
+
export type { Ambiguity, Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
|
|
4
|
+
export type { ParsedEvent } from './event.js';
|
|
3
5
|
/**
|
|
4
6
|
* 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
|
|
5
7
|
*
|
|
@@ -36,3 +38,18 @@ export declare function parse(text: string, options?: ParseOptions): ParseResult
|
|
|
36
38
|
* parseDate('아무 날짜도 없음'); // → null
|
|
37
39
|
*/
|
|
38
40
|
export declare function parseDate(text: string, options?: ParseOptions): Date | null;
|
|
41
|
+
/**
|
|
42
|
+
* 할 일·일정 한 줄을 제목과 시점으로 나눈다. 날짜로 인식된 부분과 거기 붙은 조사를 뺀 나머지가 제목.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* parseEvent('다음주 금요일까지 보고서 제출');
|
|
46
|
+
* // → { title: '보고서 제출', date: 기준 시각, end: 금요일 23:59, hasTime: false }
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* parseEvent('매주 월요일 오전 9시 팀 회의');
|
|
50
|
+
* // → { title: '팀 회의', date: 다음 월요일 09:00, hasTime: true, recurrence: { rrule: 'FREQ=WEEKLY;BYDAY=MO', ... } }
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* parseEvent('우유 사기'); // → { title: '우유 사기', date: null, hasTime: false }
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseEvent(text: string, options?: ParseOptions): ParsedEvent;
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,12 @@ import { relativeMonth } from './parsers/relativeMonth.js';
|
|
|
6
6
|
import { relativeYear } from './parsers/relativeYear.js';
|
|
7
7
|
import { time } from './parsers/time.js';
|
|
8
8
|
import { duration } from './parsers/duration.js';
|
|
9
|
+
import { recurrence } from './parsers/recurrence.js';
|
|
10
|
+
import { holiday } from './parsers/holiday.js';
|
|
9
11
|
import { alignEnd, groupFragments, isDayOnly, isTimeOnly, isWeekdayOnly, merge, resolve } from './resolve.js';
|
|
10
|
-
|
|
12
|
+
import { fromWallClock, toWallClock } from './util/timezone.js';
|
|
13
|
+
import { toEvent } from './event.js';
|
|
14
|
+
const PARSERS = [relativeDay, offset, weekday, absoluteDate, relativeMonth, relativeYear, time, duration, recurrence, holiday];
|
|
11
15
|
// "~까지" / "~부터" 접미 감지. "3시~5시"의 "~"도 시작 표시로 본다
|
|
12
16
|
const UNTIL = /^\s*(전까지|까지|이내|안에|안으로|내로|중으로)/;
|
|
13
17
|
const FROM = /^\s*(부터|~)\s*/;
|
|
@@ -45,6 +49,23 @@ function dedupe(frags) {
|
|
|
45
49
|
*/
|
|
46
50
|
export function parseAll(text, options) {
|
|
47
51
|
const opts = withDefaults(options);
|
|
52
|
+
const timezone = options?.timezone;
|
|
53
|
+
if (timezone === undefined)
|
|
54
|
+
return parseLocal(text, opts);
|
|
55
|
+
// 그 시간대의 벽시계로 계산하고, 결과를 실제 시점으로 되돌린다 (util/timezone.ts)
|
|
56
|
+
const local = parseLocal(text, { ...opts, referenceDate: toWallClock(opts.referenceDate, timezone) });
|
|
57
|
+
return local.map((r) => {
|
|
58
|
+
const out = { ...r, date: fromWallClock(r.date, timezone) };
|
|
59
|
+
if (r.end)
|
|
60
|
+
out.end = fromWallClock(r.end, timezone);
|
|
61
|
+
if (r.ambiguity) {
|
|
62
|
+
out.ambiguity = { ...r.ambiguity, alternatives: r.ambiguity.alternatives.map((a) => fromWallClock(a, timezone)) };
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** 실행 환경의 로컬 시간대로 계산 */
|
|
68
|
+
function parseLocal(text, opts) {
|
|
48
69
|
const frags = dedupe(PARSERS.flatMap((p) => p(text)));
|
|
49
70
|
const groups = groupFragments(frags, text);
|
|
50
71
|
const results = [];
|
|
@@ -66,8 +87,10 @@ export function parseAll(text, options) {
|
|
|
66
87
|
if (until)
|
|
67
88
|
end += until[0].length;
|
|
68
89
|
delete r._end;
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
// 조각이 공백으로 시작할 수 있다 ("오후 3시" 앞의 공백). index는 첫 글자를 가리키게
|
|
91
|
+
const raw = text.slice(g[0].index, end);
|
|
92
|
+
r.index = g[0].index + (raw.length - raw.trimStart().length);
|
|
93
|
+
r.text = raw.trim();
|
|
71
94
|
results.push(r);
|
|
72
95
|
comps.push(merge(g.map((f) => f.components)));
|
|
73
96
|
}
|
|
@@ -76,9 +99,12 @@ export function parseAll(text, options) {
|
|
|
76
99
|
for (let i = 0; i < results.length; i++) {
|
|
77
100
|
const a = results[i], b = results[i + 1];
|
|
78
101
|
const from = FROM.exec(text.slice(a.index + a.text.length));
|
|
79
|
-
// "~"는 바로 뒤에 B가 붙어 있을 때만. "부터"는 B가 "까지"로
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
// "~"는 바로 뒤에 B가 붙어 있을 때만. "부터"는 B가 "까지"로 끝나거나,
|
|
103
|
+
// 바로 뒤에 붙은 B가 시각·일뿐일 때 ("2시부터 4시", "1일부터 3일")
|
|
104
|
+
const adjacent = b !== undefined && from !== null && b.index === a.index + a.text.length + from[0].length;
|
|
105
|
+
const tilde = from?.[1] === '~' && adjacent;
|
|
106
|
+
const bareFrom = from?.[1] === '부터' && adjacent && (isTimeOnly(comps[i + 1]) || isDayOnly(comps[i + 1]));
|
|
107
|
+
if (b && from && (b.end || tilde || bareFrom)) {
|
|
82
108
|
const bc = comps[i + 1];
|
|
83
109
|
let end = b.end ?? b.date;
|
|
84
110
|
if (isTimeOnly(bc)) {
|
|
@@ -99,6 +125,11 @@ export function parseAll(text, options) {
|
|
|
99
125
|
if (!b.end && !b.hasTime)
|
|
100
126
|
end = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 23, 59);
|
|
101
127
|
}
|
|
128
|
+
// "추석 ~ 11월"처럼 끝이 시작보다 앞이면 구간이 아니라 별개 표현
|
|
129
|
+
if (end < a.date) {
|
|
130
|
+
merged.push(a);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
102
133
|
merged.push({ ...a, end, text: text.slice(a.index, b.index + b.text.length).trim() });
|
|
103
134
|
i++;
|
|
104
135
|
}
|
|
@@ -136,3 +167,20 @@ export function parse(text, options) {
|
|
|
136
167
|
export function parseDate(text, options) {
|
|
137
168
|
return parse(text, options)?.date ?? null;
|
|
138
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* 할 일·일정 한 줄을 제목과 시점으로 나눈다. 날짜로 인식된 부분과 거기 붙은 조사를 뺀 나머지가 제목.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* parseEvent('다음주 금요일까지 보고서 제출');
|
|
175
|
+
* // → { title: '보고서 제출', date: 기준 시각, end: 금요일 23:59, hasTime: false }
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* parseEvent('매주 월요일 오전 9시 팀 회의');
|
|
179
|
+
* // → { title: '팀 회의', date: 다음 월요일 09:00, hasTime: true, recurrence: { rrule: 'FREQ=WEEKLY;BYDAY=MO', ... } }
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* parseEvent('우유 사기'); // → { title: '우유 사기', date: null, hasTime: false }
|
|
183
|
+
*/
|
|
184
|
+
export function parseEvent(text, options) {
|
|
185
|
+
return toEvent(text, parseAll(text, options));
|
|
186
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { parseKoreanNumber } from '../util/number.js';
|
|
2
2
|
// 2026년 11월 5일 / 26년 11월 5일 / 11월 5일 / 11/5 / 2026-11-05 / 26.11.5 / 11.5(월일로 해석)
|
|
3
3
|
const FULL = /(?:(?<!\d)(\d{4}|\d{2})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일(?!간|\s*동안)/g;
|
|
4
|
-
|
|
4
|
+
// "2027-01-01T09:00" (ISO 8601 날짜+시각)
|
|
5
|
+
const ISO = /(?<![\d/.-])(\d{4}|\d{2})[-./]\s*(\d{1,2})[-./]\s*(\d{1,2})(?!\d)(?:T(\d{2}):(\d{2}))?/g;
|
|
5
6
|
const SLASH = /(?<![\d/.-])(\d{1,2})\s*[/.]\s*(\d{1,2})(?![\d/.:])/g;
|
|
6
7
|
// "십일월 오일", "유월 이십오일" (오탐 방지를 위해 월과 일이 모두 있을 때만)
|
|
7
8
|
const HANGUL = /(?<![가-힣])(유|시|[일이삼사오육칠팔구십]{1,2})월\s*([일이삼사오육칠팔구십]{1,3})일(?!요일)/g;
|
|
@@ -35,7 +36,10 @@ export const absoluteDate = (text) => {
|
|
|
35
36
|
continue;
|
|
36
37
|
if (!take(m.index, m[0].length))
|
|
37
38
|
continue;
|
|
38
|
-
|
|
39
|
+
const c = { year: expandYear(m[1]), month, day };
|
|
40
|
+
if (m[4] && +m[4] < 24 && +m[5] < 60)
|
|
41
|
+
Object.assign(c, { hour: +m[4], minute: +m[5], meridiem: true });
|
|
42
|
+
out.push({ text: m[0], index: m.index, components: c });
|
|
39
43
|
}
|
|
40
44
|
for (const m of text.matchAll(FULL)) {
|
|
41
45
|
if (!take(m.index, m[0].length))
|
package/dist/parsers/duration.js
CHANGED
|
@@ -7,12 +7,14 @@ const NIGHTS = /(\d+)\s*박\s*(\d+)\s*일/g;
|
|
|
7
7
|
export const duration = (text) => {
|
|
8
8
|
const out = [];
|
|
9
9
|
for (const m of text.matchAll(NIGHTS)) {
|
|
10
|
+
if (+m[2] === 0)
|
|
11
|
+
continue;
|
|
10
12
|
out.push({ text: m[0], index: m.index, components: { durationDays: +m[2] } });
|
|
11
13
|
}
|
|
12
14
|
for (const m of text.matchAll(RE)) {
|
|
13
15
|
const n = parseKoreanNumber(m[3] ?? m[1]);
|
|
14
|
-
if (n
|
|
15
|
-
continue;
|
|
16
|
+
if (!n)
|
|
17
|
+
continue; // "0일간"은 기간이 아니다
|
|
16
18
|
const c = {};
|
|
17
19
|
switch (m[3] ? '일' : m[2]) {
|
|
18
20
|
case '일':
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 양력 고정 공휴일·기념일 → [월, 일]. 여러 단어는 띄어쓰기를 생략해도 된다
|
|
2
|
+
const FIXED = {
|
|
3
|
+
'크리스마스 이브': [12, 24],
|
|
4
|
+
크리스마스: [12, 25],
|
|
5
|
+
성탄절: [12, 25],
|
|
6
|
+
신정: [1, 1],
|
|
7
|
+
삼일절: [3, 1],
|
|
8
|
+
'근로자의 날': [5, 1],
|
|
9
|
+
노동절: [5, 1],
|
|
10
|
+
어린이날: [5, 5],
|
|
11
|
+
어버이날: [5, 8],
|
|
12
|
+
'스승의 날': [5, 15],
|
|
13
|
+
현충일: [6, 6],
|
|
14
|
+
제헌절: [7, 17],
|
|
15
|
+
광복절: [8, 15],
|
|
16
|
+
개천절: [10, 3],
|
|
17
|
+
한글날: [10, 9],
|
|
18
|
+
할로윈: [10, 31],
|
|
19
|
+
핼러윈: [10, 31],
|
|
20
|
+
빼빼로데이: [11, 11],
|
|
21
|
+
발렌타인데이: [2, 14],
|
|
22
|
+
밸런타인데이: [2, 14],
|
|
23
|
+
화이트데이: [3, 14],
|
|
24
|
+
};
|
|
25
|
+
const SHIFT = { 전날: -1, 다음날: 1, 이튿날: 1, 당일: 0 };
|
|
26
|
+
const MODIFIER = '(?:\\s*(전날|다음\\s*날|이튿날|당일))?';
|
|
27
|
+
// "크리스마스", "광복절 전날", "스승의날"
|
|
28
|
+
const FIXED_RE = new RegExp(`(${Object.keys(FIXED).map((k) => k.replace(/ /g, '\\s*')).join('|')})${MODIFIER}`, 'g');
|
|
29
|
+
// "설날", "구정", "설 연휴", "추석 전날", "한가위", "부처님 오신 날". "설"만은 "설명", "설거지"와 겹쳐서 뒤에 연휴·전날 등이 올 때만
|
|
30
|
+
const LUNAR_RE = /(설날|구정|설(?=\s*(?:연휴|전날|다음\s*날|당일))|추석|한가위|부처님\s*오신\s*날|석가\s*탄신일|석탄일|초파일)(?:\s*(연휴))?(?:\s*(전날|다음\s*날|이튿날|당일))?/g;
|
|
31
|
+
// "음력 3월 3일", "음력 윤5월 1일" (음력이라고 말한 경우만)
|
|
32
|
+
const LUNAR_DATE_RE = /음력\s*(윤\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;
|
|
33
|
+
const fixedKey = (name) => Object.keys(FIXED).find((k) => k.replace(/ /g, '') === name.replace(/\s/g, ''));
|
|
34
|
+
const shiftOf = (m) => (m ? SHIFT[m.replace(/\s/g, '')] : 0);
|
|
35
|
+
/** 공휴일·기념일·명절. 음력 명절의 양력 날짜는 resolve가 표에서 찾는다 */
|
|
36
|
+
export const holiday = (text) => {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const m of text.matchAll(FIXED_RE)) {
|
|
39
|
+
const [month, day] = FIXED[fixedKey(m[1])];
|
|
40
|
+
const c = { month, day };
|
|
41
|
+
const shift = shiftOf(m[2]);
|
|
42
|
+
if (shift)
|
|
43
|
+
c.dayOffset = shift;
|
|
44
|
+
out.push({ text: m[0], index: m.index, components: c });
|
|
45
|
+
}
|
|
46
|
+
for (const m of text.matchAll(LUNAR_DATE_RE)) {
|
|
47
|
+
const month = +m[2], day = +m[3];
|
|
48
|
+
if (month < 1 || month > 12 || day < 1 || day > 30)
|
|
49
|
+
continue;
|
|
50
|
+
out.push({ text: m[0], index: m.index, components: { lunarDate: { month, day, leap: !!m[1] } } });
|
|
51
|
+
}
|
|
52
|
+
for (const m of text.matchAll(LUNAR_RE)) {
|
|
53
|
+
const name = m[1].replace(/\s/g, '');
|
|
54
|
+
const lunar = name === '설날' || name === '구정' || name === '설' ? 'seollal'
|
|
55
|
+
: name === '추석' || name === '한가위' ? 'chuseok' : 'buddha';
|
|
56
|
+
const c = { lunar };
|
|
57
|
+
// "설 연휴", "추석 연휴" = 전날부터 다음날까지 3일 (부처님 오신 날은 연휴가 없다)
|
|
58
|
+
if (m[2] && lunar !== 'buddha')
|
|
59
|
+
Object.assign(c, { dayOffset: -1, durationDays: 3 });
|
|
60
|
+
else {
|
|
61
|
+
const shift = shiftOf(m[3]);
|
|
62
|
+
if (shift)
|
|
63
|
+
c.dayOffset = shift;
|
|
64
|
+
}
|
|
65
|
+
out.push({ text: m[0], index: m.index, components: c });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
};
|
package/dist/parsers/offset.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { parseKoreanNumber } from '../util/number.js';
|
|
2
|
-
// "3일 뒤", "2주 후", "한 달 전", "세 시간 뒤", "30분 후", "열흘 뒤", "이틀 뒤", "1년 뒤", "30분 내로"
|
|
3
|
-
const RE = /(?:(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*(일|주|주일|달|개월|년|시간|분)|(보름|열흘|이틀|사흘|나흘|닷새|엿새|이레|여드레|아흐레))\s*(뒤|후|전|이내|안에|안으로|내로|이후)/g;
|
|
2
|
+
// "3일 뒤", "2주 후", "한 달 전", "세 시간 뒤", "30분 후", "열흘 뒤", "이틀 뒤", "1년 뒤", "30분 내로", "30분쯤 뒤"
|
|
3
|
+
const RE = /(?:(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*(일|주|주일|달|개월|년|시간|분)|(보름|열흘|이틀|사흘|나흘|닷새|엿새|이레|여드레|아흐레))\s*(?:쯤|정도)?\s*(뒤|후|전|이내|안에|안으로|내로|이후)/g;
|
|
4
4
|
// "한 시간 반 뒤", "1시간 30분 후", "2시간반 전"
|
|
5
|
-
const HOUR_MIN = /(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*시간\s*(?:(반)|(\d+)\s*분)\s*(뒤|후|전|이내|안에|이후)/g;
|
|
5
|
+
const HOUR_MIN = /(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*시간\s*(?:(반)|(\d+)\s*분)\s*(?:쯤|정도)?\s*(뒤|후|전|이내|안에|이후)/g;
|
|
6
6
|
/** "N일 뒤 / N주 후 / N달 전 / N시간 뒤 / N분 후" */
|
|
7
7
|
export const offset = (text) => {
|
|
8
8
|
const out = [];
|
|
@@ -39,6 +39,9 @@ export const offset = (text) => {
|
|
|
39
39
|
c.monthOffset = sign * n;
|
|
40
40
|
break;
|
|
41
41
|
case '년':
|
|
42
|
+
// "2028년 후"는 "2028년 뒤"가 아니라 "2028년 이후"일 가능성이 크다. 100년 넘게는 상대 이동으로 보지 않는다
|
|
43
|
+
if (n > 100)
|
|
44
|
+
continue;
|
|
42
45
|
c.yearOffset = sign * n;
|
|
43
46
|
c.sameDay = true;
|
|
44
47
|
break;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { parseKoreanNumber } from '../util/number.js';
|
|
2
|
+
const WEEKDAY = { 일: 0, 월: 1, 화: 2, 수: 3, 목: 4, 금: 5, 토: 6 };
|
|
3
|
+
const NTH = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 마지막: -1 };
|
|
4
|
+
const SETS = { 평일: [1, 2, 3, 4, 5], 주중: [1, 2, 3, 4, 5], 주말: [6, 0] };
|
|
5
|
+
// 요일 목록 "월수금", "화, 목", "월요일이랑 수요일", "월~금". 목록이 끝난 뒤에는 다른 한글이 붙으면 안 된다
|
|
6
|
+
// ("매주 일요일 일정"의 "일"을 요일로 잡지 않게). 조사·마다는 허용
|
|
7
|
+
const ITEM = '[월화수목금토일](?:요일|욜)?(?:\\s*~\\s*[월화수목금토일](?:요일|욜)?)?';
|
|
8
|
+
const LIST = `${ITEM}(?:\\s*(?:,|·|/|이랑|랑|하고|과|와)?\\s*${ITEM})*`;
|
|
9
|
+
const LIST_END = '(?:(?![가-힣])|(?=마다|에|엔|은|는|도))';
|
|
10
|
+
// "매달 첫째 주 월요일", "매월 마지막 주 금요일"
|
|
11
|
+
const MONTHLY_NTH = /(?:매달|매월|매\s*달)\s*(첫째|첫|둘째|셋째|넷째|마지막)\s*주\s*([월화수목금토일])(?:요일|욜)/g;
|
|
12
|
+
// "매달 15일", "매월 말일", "매달 말", "매달 초"
|
|
13
|
+
const MONTHLY_DAY = /(?:매달|매월|매\s*달|다달이)\s*(?:(\d{1,2})\s*일|(말일|마지막\s*날|말)|(초))/g;
|
|
14
|
+
// "매년 3월 1일", "해마다 12월 25일"
|
|
15
|
+
const YEARLY = /(?:매년|매해|해마다)\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;
|
|
16
|
+
// "매주 평일", "평일마다", "주말마다", "평일 매일"
|
|
17
|
+
const WEEKDAY_SET = /매주\s*(평일|주중|주말)|(?<![가-힣])(평일|주중|주말)\s*(?:마다|매일)/g;
|
|
18
|
+
// "매주 월요일", "매주 월수금", "격주 금요일", "2주마다 수요일", "2주에 한 번 화요일"
|
|
19
|
+
const WEEKLY = new RegExp(`(매주|매\\s*주|격주|(\\d{1,2}|두|세|네)\\s*주\\s*(?:마다|에\\s*한\\s*번))\\s*(${LIST})${LIST_END}(?:\\s*마다)?`, 'g');
|
|
20
|
+
// "월요일마다", "화요일이랑 목요일마다"
|
|
21
|
+
const DAYS_EVERY = /(?<![가-힣])([월화수목금토일](?:요일|욜)(?:\s*(?:,|·|이랑|랑|하고|과|와)?\s*[월화수목금토일](?:요일|욜))*)\s*마다/g;
|
|
22
|
+
// "매일", "날마다", "매일매일"
|
|
23
|
+
const DAILY = /(?<![가-힣])(?:매일매일|매일|날마다)/g;
|
|
24
|
+
// "2시간마다", "30분마다", "매시간"
|
|
25
|
+
const HOURLY = /(?<![가-힣\d])(?:(\d{1,2}|한|두|세|네|다섯|여섯)\s*시간\s*마다|매\s*시간)/g;
|
|
26
|
+
const MINUTELY = /(?<!\d)(\d{1,3})\s*분\s*마다/g;
|
|
27
|
+
/** "월수금", "화, 목요일", "월~금" → 요일 번호들. "요일" 안의 "일"은 일요일이 아니다 */
|
|
28
|
+
function weekdaysOf(list) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const plain = list.replace(/요일|욜/g, '');
|
|
31
|
+
// "월~금" → 월화수목금 (월요일부터 센다, "금~일"은 금토일)
|
|
32
|
+
for (const m of plain.matchAll(/([월화수목금토일])\s*~\s*([월화수목금토일])|([월화수목금토일])/g)) {
|
|
33
|
+
if (m[3]) {
|
|
34
|
+
out.push(WEEKDAY[m[3]]);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const from = (WEEKDAY[m[1]] + 6) % 7, to = (WEEKDAY[m[2]] + 6) % 7;
|
|
38
|
+
for (let i = from; i !== (to + 1) % 7; i = (i + 1) % 7)
|
|
39
|
+
out.push((i + 1) % 7);
|
|
40
|
+
}
|
|
41
|
+
return [...new Set(out)];
|
|
42
|
+
}
|
|
43
|
+
/** 반복 일정. 날짜는 계산하지 않고 규칙만 뽑는다 (첫 발생일은 resolve가 계산) */
|
|
44
|
+
export const recurrence = (text) => {
|
|
45
|
+
const out = [];
|
|
46
|
+
const taken = [];
|
|
47
|
+
const push = (m, recur) => {
|
|
48
|
+
const s = m.index, e = s + m[0].length;
|
|
49
|
+
if (taken.some(([a, b]) => s < b && e > a))
|
|
50
|
+
return;
|
|
51
|
+
taken.push([s, e]);
|
|
52
|
+
out.push({ text: m[0], index: s, components: { recur } });
|
|
53
|
+
};
|
|
54
|
+
for (const m of text.matchAll(MONTHLY_NTH)) {
|
|
55
|
+
push(m, { freq: 'MONTHLY', interval: 1, byWeekday: [WEEKDAY[m[2]]], byWeekdayNth: NTH[m[1]] });
|
|
56
|
+
}
|
|
57
|
+
for (const m of text.matchAll(MONTHLY_DAY)) {
|
|
58
|
+
const day = m[1] ? +m[1] : m[2] ? -1 : 1;
|
|
59
|
+
if (day === 0 || day > 31)
|
|
60
|
+
continue;
|
|
61
|
+
push(m, { freq: 'MONTHLY', interval: 1, byMonthDay: day });
|
|
62
|
+
}
|
|
63
|
+
for (const m of text.matchAll(YEARLY)) {
|
|
64
|
+
const month = +m[1], day = +m[2];
|
|
65
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
66
|
+
continue;
|
|
67
|
+
push(m, { freq: 'YEARLY', interval: 1, byMonth: month, byMonthDay: day });
|
|
68
|
+
}
|
|
69
|
+
for (const m of text.matchAll(WEEKDAY_SET)) {
|
|
70
|
+
push(m, { freq: 'WEEKLY', interval: 1, byWeekday: SETS[m[1] ?? m[2]] });
|
|
71
|
+
}
|
|
72
|
+
for (const m of text.matchAll(WEEKLY)) {
|
|
73
|
+
const n = m[1].startsWith('격주') ? 2 : m[2] ? parseKoreanNumber(m[2]) : 1;
|
|
74
|
+
if (!n)
|
|
75
|
+
continue;
|
|
76
|
+
push(m, { freq: 'WEEKLY', interval: n, byWeekday: weekdaysOf(m[3]) });
|
|
77
|
+
}
|
|
78
|
+
for (const m of text.matchAll(DAYS_EVERY)) {
|
|
79
|
+
push(m, { freq: 'WEEKLY', interval: 1, byWeekday: weekdaysOf(m[1]) });
|
|
80
|
+
}
|
|
81
|
+
for (const m of text.matchAll(DAILY)) {
|
|
82
|
+
push(m, { freq: 'DAILY', interval: 1 });
|
|
83
|
+
}
|
|
84
|
+
for (const m of text.matchAll(HOURLY)) {
|
|
85
|
+
const n = m[1] ? parseKoreanNumber(m[1]) : 1;
|
|
86
|
+
if (!n)
|
|
87
|
+
continue;
|
|
88
|
+
push(m, { freq: 'HOURLY', interval: n });
|
|
89
|
+
}
|
|
90
|
+
for (const m of text.matchAll(MINUTELY)) {
|
|
91
|
+
if (+m[1] === 0)
|
|
92
|
+
continue;
|
|
93
|
+
push(m, { freq: 'MINUTELY', interval: +m[1] });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
const CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
|
98
|
+
/** RFC 5545 RRULE 문자열. 요일은 월요일부터 */
|
|
99
|
+
export function toRRule(r) {
|
|
100
|
+
const parts = [`FREQ=${r.freq}`];
|
|
101
|
+
if (r.interval > 1)
|
|
102
|
+
parts.push(`INTERVAL=${r.interval}`);
|
|
103
|
+
if (r.byMonth !== undefined)
|
|
104
|
+
parts.push(`BYMONTH=${r.byMonth}`);
|
|
105
|
+
if (r.byMonthDay !== undefined)
|
|
106
|
+
parts.push(`BYMONTHDAY=${r.byMonthDay}`);
|
|
107
|
+
if (r.byWeekday?.length) {
|
|
108
|
+
const days = [...r.byWeekday].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7)).map((d) => CODE[d]);
|
|
109
|
+
parts.push(`BYDAY=${days.map((c) => (r.byWeekdayNth ? `${r.byWeekdayNth}${c}` : c)).join(',')}`);
|
|
110
|
+
}
|
|
111
|
+
return parts.join(';');
|
|
112
|
+
}
|
package/dist/parsers/time.js
CHANGED
|
@@ -4,7 +4,7 @@ const CLOCK = /(오전|오후|새벽|아침|저녁|밤|낮|정오)?\s*(\d{1,2}|
|
|
|
4
4
|
// "5시 10분 전" → 4:50
|
|
5
5
|
const BEFORE = /(오전|오후|새벽|아침|저녁|밤|낮)?\s*(\d{1,2}|한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열두)\s*시\s*(\d{1,2})\s*분\s*전(?=[\s,에까]|$)/g;
|
|
6
6
|
// "오전 중으로", "오후 중에" → 그 시간대의 끝(12시/18시)까지
|
|
7
|
-
const HALF_DAY = /(오전|오후)\s*중(?:으로|에는|에)?/g;
|
|
7
|
+
const HALF_DAY = /(오전|오후)\s*중(?!순)(?:으로|에는|에)?/g;
|
|
8
8
|
// "3pm", "9 AM", "12pm"
|
|
9
9
|
const AMPM = /(?<![\d:])(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\.?(?![a-z])/gi;
|
|
10
10
|
// "3~5시", "오후 3~5시" → "오후 3" + "5시" 두 조각 (index.ts가 "~"로 구간을 합친다)
|
|
@@ -13,7 +13,14 @@ const SHORT_RANGE = /(오전|오후|새벽|아침|저녁|밤|낮)?\s*(\d{1,2}|
|
|
|
13
13
|
const COLON = /(?:(오전|오후|새벽|아침|저녁|밤|낮)\s*)?(?<!\d)(\d{1,2}):(\d{2})(?!\d)/g;
|
|
14
14
|
// 단독 시간 단어. "낮"은 "낮다"와 겹쳐서 뒤에 공백/조사가 올 때만
|
|
15
15
|
const WORD = /(정오|자정|점심|아침|저녁|밤(?!새|낮)|퇴근|낮(?=[\s에,]|$))(?!\s*\d)/g;
|
|
16
|
-
|
|
16
|
+
// "내일 오후", "오전에" → 9시/15시 (규칙 25). 뒤에 조사·공백·끝만 허용하고,
|
|
17
|
+
// "오전반", "오후 반차", "오전오후"처럼 다른 낱말이 붙으면 잡지 않는다
|
|
18
|
+
const HALF_WORD = /(?<![가-힣])(오전|오후)(?=(?:에는|에|엔|은|는|도|쯤|께)?(?:[\s,.!?~]|$))(?!\s*(?:반|오전|오후|\d))/g;
|
|
19
|
+
const WORD_HOUR = {
|
|
20
|
+
정오: 12, 자정: 0, 점심: 12, 아침: 8, 저녁: 19, 밤: 21, 퇴근: 18, 낮: 12, 오전: 9, 오후: 15,
|
|
21
|
+
};
|
|
22
|
+
// "내일 이 시간", "다음 주 이 시간" → 기준 시각의 시:분. "같이 시간 내자"는 잡지 않는다
|
|
23
|
+
const SAME_TIME = /(?<![가-힣])이\s*시간(?!대|\s*(?:뒤|후|전|동안|이내|안에))/g;
|
|
17
24
|
function applyMeridiem(prefix, hour) {
|
|
18
25
|
if (hour === 24)
|
|
19
26
|
return 0;
|
|
@@ -116,12 +123,25 @@ export const time = (text) => {
|
|
|
116
123
|
const hour = applyMeridiem(m[1], raw);
|
|
117
124
|
out.push({ text: m[0], index: m.index, components: { hour, minute, meridiem: true } });
|
|
118
125
|
}
|
|
126
|
+
for (const m of text.matchAll(SAME_TIME)) {
|
|
127
|
+
if (!take(m.index, m[0].length))
|
|
128
|
+
continue;
|
|
129
|
+
out.push({ text: m[0], index: m.index, components: { sameTime: true } });
|
|
130
|
+
}
|
|
131
|
+
for (const m of text.matchAll(HALF_WORD)) {
|
|
132
|
+
if (!take(m.index, m[0].length))
|
|
133
|
+
continue;
|
|
134
|
+
out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true, approximate: true } });
|
|
135
|
+
}
|
|
119
136
|
for (const m of text.matchAll(WORD)) {
|
|
120
137
|
if (!take(m.index, m[0].length))
|
|
121
138
|
continue;
|
|
122
139
|
const c = { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true };
|
|
123
140
|
if (m[1] === '자정')
|
|
124
141
|
c.midnight = true;
|
|
142
|
+
// 정오·자정은 정확한 시각, 나머지(아침·저녁·밤·점심·낮·퇴근)는 관례
|
|
143
|
+
else if (m[1] !== '정오')
|
|
144
|
+
c.approximate = true;
|
|
125
145
|
out.push({ text: m[0], index: m.index, components: c });
|
|
126
146
|
}
|
|
127
147
|
return out;
|
package/dist/parsers/weekday.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseKoreanNumber } from '../util/number.js';
|
|
1
2
|
const WEEKDAY = { 일: 0, 월: 1, 화: 2, 수: 3, 목: 4, 금: 5, 토: 6 };
|
|
2
3
|
const WEEK_WORDS = {
|
|
3
4
|
지지난: -2, 저저번: -2, 지난: -1, 저번: -1, 이번: 0, 다음: 1, 다다음: 2, 다담: 2, 담: 1,
|
|
@@ -14,6 +15,8 @@ const PAREN = new RegExp(`\\((${DAY})\\)`, 'g');
|
|
|
14
15
|
// 요일 글자만 쓸 때("첫째 주 월")는 뒤에 한글이 없어야 한다 ("둘째 주 일정"의 "일"은 요일이 아님)
|
|
15
16
|
const NTH = new RegExp(`(?:(\\d{1,2})\\s*월\\s*)?(첫째|첫|둘째|셋째|넷째|다섯째|마지막)\\s*주(?:\\s*(${DAY})(?:요일|욜|(?![가-힣])))?`, 'g');
|
|
16
17
|
const NTH_NUM = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 다섯째: 5, 마지막: -1 };
|
|
18
|
+
// "2주 뒤 월요일", "3주 후 금요일" → N주 뒤의 그 주의 X요일 ("다다음주 월요일"과 같은 계산)
|
|
19
|
+
const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:뒤|후)\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
17
20
|
// "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
|
|
18
21
|
const DIR = new RegExp(`(지난|저번|다음)\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
19
22
|
// "주말에 보자" ("이번 주말"은 위 WEEKEND가 먼저 잡는다)
|
|
@@ -29,6 +32,13 @@ export const weekday = (text) => {
|
|
|
29
32
|
const out = [];
|
|
30
33
|
const taken = [];
|
|
31
34
|
const overlaps = (i, len) => taken.some(([s, e]) => i < e && i + len > s);
|
|
35
|
+
for (const m of text.matchAll(WEEKS_LATER)) {
|
|
36
|
+
const n = parseKoreanNumber(m[1]);
|
|
37
|
+
if (n == null)
|
|
38
|
+
continue;
|
|
39
|
+
out.push({ text: m[0], index: m.index, components: { weekOffset: n, weekday: WEEKDAY[m[2]] } });
|
|
40
|
+
taken.push([m.index, m.index + m[0].length]);
|
|
41
|
+
}
|
|
32
42
|
for (const m of text.matchAll(NTH)) {
|
|
33
43
|
const c = { nthWeek: NTH_NUM[m[2]] };
|
|
34
44
|
if (m[1]) {
|
package/dist/resolve.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Components, Fragment,
|
|
1
|
+
import type { Components, Fragment, ParseResult, ResolveOptions } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* 인접한 조각(fragment)을 하나로 합친다.
|
|
4
4
|
* 예: "다음주 화요일" + "오후 3시" → 하나의 결과.
|
|
@@ -18,4 +18,4 @@ export declare function isDayOnly(c: Components): boolean;
|
|
|
18
18
|
* "오후 3~5시" → 17시, "오후 11시부터 1시까지" → 다음날 1시, "오후 3시부터 오전 1시까지" → 다음날 1시
|
|
19
19
|
*/
|
|
20
20
|
export declare function alignEnd(start: Date, end: Components): Date;
|
|
21
|
-
export declare function resolve(group: Fragment[], opts:
|
|
21
|
+
export declare function resolve(group: Fragment[], opts: ResolveOptions): ParseResult | null;
|