ko-chrono 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 최지원
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # ko-chrono
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 한국어 자연어 날짜/시간 파서. 문장 속 "다음주 화요일 오후 3시"를 `Date`로 바꿉니다.
4
+
5
+ [chrono-node](https://github.com/wanasit/chrono)에 없는 한국어를 채웁니다.
6
+ [es-hangul](https://github.com/toss/es-hangul)이 글자를 다룬다면, ko-chrono는 문장 속 시간을 다룹니다.
7
+
8
+ ```ts
9
+ import { parse, parseAll, parseDate } from 'ko-chrono';
10
+
11
+ // 기준: 2026-10-30 (금) 14:00
12
+ parseDate('다음주 화요일 오후 3시'); // 2026-11-03 15:00
13
+ parseDate('모레 점심'); // 2026-11-01 12:00
14
+ parseDate('토요일 아침'); // 2026-10-31 08:00
15
+ parseDate('이번 주말'); // 2026-10-31 (토)
16
+ parseDate('3일 뒤'); // 2026-11-02
17
+ parseDate('한 시간 반 뒤'); // 2026-10-30 15:30
18
+ parseDate('다음달 중순'); // 2026-11-15
19
+ parseDate('내년 초'); // 2027-01-01
20
+ parseDate('11/5 10시 반'); // 2026-11-05 10:30
21
+ parseDate('26년 12월 1일'); // 2026-12-01
22
+ parseDate('십일월 오일'); // 2026-11-05
23
+ parseDate('내일은 오후3시쯤'); // 2026-10-31 15:00
24
+ parseDate('11월 첫째 주 월요일'); // 2026-11-02
25
+ parseDate('지난 금요일'); // 2026-10-23
26
+ parseDate('11월 31일'); // null — 없는 날짜는 넘기지 않음
27
+
28
+ parse('이번달 말까지 제출');
29
+ // { date: 지금, end: 2026-10-31 23:59, text: '이번달 말까지', hasTime: false }
30
+
31
+ parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?');
32
+ // { date: 2026-11-04 19:00, text: '다음주 수요일 저녁 7시', index: 3, hasTime: true }
33
+
34
+ parse('오늘부터 3일간 휴가');
35
+ // { date: 2026-10-30, end: 2026-11-01 23:59, text: '오늘부터 3일간' }
36
+
37
+ parseAll('내일 10시 아니면 모레 2시'); // [10-31 10:00, 11-01 14:00]
38
+ ```
39
+
40
+ - 런타임 의존성 0
41
+ - 브라우저 / Node 모두 동작
42
+ - 트리쉐이킹 가능한 ESM
43
+
44
+ ## 설치
45
+
46
+ ```bash
47
+ npm install ko-chrono
48
+ ```
49
+
50
+ ## API
51
+
52
+ ### `parse(text, options?)`
53
+ 첫 번째 날짜/시간 표현을 찾아 `ParseResult`를 돌려줍니다. 없으면 `null`.
54
+
55
+ ### `parseAll(text, options?)`
56
+ 모든 표현을 배열로.
57
+
58
+ ### `parseDate(text, options?)`
59
+ `parse(...)?.date` 단축형.
60
+
61
+ ### 옵션
62
+
63
+ | 옵션 | 기본값 | 설명 |
64
+ |---|---|---|
65
+ | `referenceDate` | `new Date()` | "내일", "다음주"의 기준 시각 |
66
+ | `weekStart` | `'monday'` | "이번주/다음주" 계산 기준 |
67
+ | `defaultHour` | `0` | 시간이 없는 날짜에 넣을 시 |
68
+
69
+ ### 해석 규칙
70
+
71
+ - "다음주 월요일" = 기준일이 속한 주의 다음 주 월요일 (금요일에 말하면 3일 뒤)
72
+ - "월요일" 단독 = 오늘 포함 가장 가까운 미래의 월요일
73
+ - "이번 주말" = 그 주의 토요일. "주말" 단독 = 다가오는 토요일 (토·일이면 오늘)
74
+ - 시간만 있으면 오늘, 이미 지났으면 내일
75
+ - 오전/오후가 없는 1~6시는 오후 ("3시에 보자" → 15:00). 7~11시는 그대로
76
+ - 월일만 있고 이미 지났으면 내년 ("올해 3월 1일"처럼 연도를 말하면 그대로). "3월", "2월 말"도 그 달이 지났으면 내년
77
+ - 날짜를 말한 시각은 지났어도 그대로 ("오늘 아침" → 오늘 08:00)
78
+ - 두 자리 연도: 00~69 → 20xx, 70~99 → 19xx
79
+ - "오후 12시" = 정오. "내일 자정" / "내일 24시" / "내일 밤 12시" = 내일이 끝나는 시점(모레 00:00)
80
+ - 시 범위의 끝은 시작의 오전·오후를 이어받음: "오후 3~5시" → 15~17시
81
+ - "11월 5일 목요일"처럼 날짜와 요일이 안 맞으면 `null` ("11/3(화)" 괄호 표기도 같음)
82
+ - "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
83
+ - "11월 첫째 주 월요일" = 11월의 첫 번째 월요일. 요일이 없으면 N번째 월요일
84
+ - "내년 이맘때", "1년 뒤"는 오늘의 월·일 유지
85
+ - "오늘부터 3일간" → `end`는 3일째 되는 날 23:59. "3일간" 단독은 날짜가 아니라서 `null`
86
+ - 없는 날짜(2월 30일, 11월 31일)는 다음 달로 넘기지 않고 `null`
87
+ - "~까지" / "~이내" → `end`에 종료 시점. "이번 주 안에", "이번달 안에"는 그 기간의 마지막 날. "이번 주 내내"는 시작~끝 구간
88
+ - "내일 30분 후"처럼 날짜와 상대 시간은 별개 결과
89
+
90
+ ## 지원 표현
91
+
92
+ 오늘·내일·모레·글피·어제·그저께 / N일·주·달·시간·분 뒤·전 / N시간 반 뒤 / 열흘·보름 / 이번주·다음주·다다음주·지난주 + 요일 / 이번·다음·지난 주말 / 요일 단독 / M월 D일 / 십일월 오일 / YYYY년 / YY년 / M/D / YYYY-MM-DD / YY.M.D / 올해·내년·작년 + 초·말 / 이번달·다음달 + 초·중순·말 / 오전·오후·새벽·아침·저녁·밤·낮 N시 (반·N분) / HH:MM / 정오·자정·점심 / ~까지·이내·안에·중으로 / A부터 B까지 / A~B / 3~5시 / 3pm / 월요일부터 금요일까지 / 월~금 / 지난·다음 X요일 / 11/3(화) / N째 주 X요일 / 마지막 주 / 이맘때 / N년 뒤 / 이틀·사흘 / 연말·연초·월초·월말 / 오전 중·퇴근 전까지 / A부터 N일간 / 3박 4일 / 이번 주 내내 / 월욜·낼·내일모레 / 띄어쓰기 생략 / 조사·어림말 (은·는·도·쯤·경·께)
93
+
94
+ 전체 목록과 예시는 [SPEC.md](./SPEC.md).
95
+
96
+ ## Playground
97
+
98
+ ```bash
99
+ npm run playground
100
+ ```
101
+
102
+ 브라우저에서 `http://localhost:3000/playground` 를 열면 직접 입력해 볼 수 있습니다.
103
+
104
+ ## 로드맵
105
+
106
+ - [ ] 반복 일정 (매주 월요일 9시)
107
+ - [ ] 명절·공휴일 기준 표현
108
+ - [ ] chrono-node `ko` 로케일 기여
109
+
110
+ ## 라이선스
111
+
112
+ MIT
@@ -0,0 +1,38 @@
1
+ import type { ParseOptions, ParseResult } from './types.js';
2
+ export type { ParseOptions, ParseResult, WeekStart } from './types.js';
3
+ /**
4
+ * 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
5
+ *
6
+ * @example
7
+ * parseAll('내일 10시 아니면 모레 2시');
8
+ * // → [{ date: 내일 10:00, text: '내일 10시', ... }, { date: 모레 14:00, text: '모레 2시', ... }]
9
+ *
10
+ * @example
11
+ * parseAll('11월 1일부터 11월 3일까지');
12
+ * // → [{ date: 11-01 00:00, end: 11-03 00:00, ... }] "A부터 B까지"는 하나의 구간으로 합쳐진다
13
+ */
14
+ export declare function parseAll(text: string, options?: ParseOptions): ParseResult[];
15
+ /**
16
+ * 문장에서 첫 번째 날짜/시간 표현을 찾는다. 없으면 null.
17
+ *
18
+ * @example
19
+ * parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?');
20
+ * // → { date: 다음주 수요일 19:00, text: '다음주 수요일 저녁 7시', index: 3, hasTime: true }
21
+ *
22
+ * @example
23
+ * parse('이번달 말까지 보내주세요');
24
+ * // → { date: 기준 시각, end: 이번달 마지막날 23:59, text: '이번달 말까지', hasTime: false, ... }
25
+ *
26
+ * @example
27
+ * parse('오후 3시', { referenceDate: new Date(2026, 9, 30, 14) });
28
+ * // → 2026-10-30 15:00 (시간만 있으면 오늘, 이미 지났으면 내일)
29
+ */
30
+ export declare function parse(text: string, options?: ParseOptions): ParseResult | null;
31
+ /**
32
+ * parse()의 date만 돌려주는 단축형. 구간이면 시작 시각.
33
+ *
34
+ * @example
35
+ * parseDate('다음주 화요일 오후 3시'); // → Date
36
+ * parseDate('아무 날짜도 없음'); // → null
37
+ */
38
+ export declare function parseDate(text: string, options?: ParseOptions): Date | null;
package/dist/index.js ADDED
@@ -0,0 +1,138 @@
1
+ import { relativeDay } from './parsers/relativeDay.js';
2
+ import { offset } from './parsers/offset.js';
3
+ import { weekday } from './parsers/weekday.js';
4
+ import { absoluteDate } from './parsers/absoluteDate.js';
5
+ import { relativeMonth } from './parsers/relativeMonth.js';
6
+ import { relativeYear } from './parsers/relativeYear.js';
7
+ import { time } from './parsers/time.js';
8
+ import { duration } from './parsers/duration.js';
9
+ import { alignEnd, groupFragments, isDayOnly, isTimeOnly, isWeekdayOnly, merge, resolve } from './resolve.js';
10
+ const PARSERS = [relativeDay, offset, weekday, absoluteDate, relativeMonth, relativeYear, time, duration];
11
+ // "~까지" / "~부터" 접미 감지. "3시~5시"의 "~"도 시작 표시로 본다
12
+ const UNTIL = /^\s*(전까지|까지|이내|안에|안으로|내로|중으로)/;
13
+ const FROM = /^\s*(부터|~)\s*/;
14
+ // "이번 주 내내" → 그 기간 전체
15
+ const WHOLE = /^\s*내내/;
16
+ function withDefaults(opts) {
17
+ return {
18
+ referenceDate: opts?.referenceDate ?? new Date(),
19
+ weekStart: opts?.weekStart ?? 'monday',
20
+ defaultHour: opts?.defaultHour ?? 0,
21
+ };
22
+ }
23
+ /** 겹치는 조각 제거: 더 긴 것 우선 */
24
+ function dedupe(frags) {
25
+ const sorted = [...frags].sort((a, b) => b.text.length - a.text.length);
26
+ const kept = [];
27
+ for (const f of sorted) {
28
+ const s = f.index, e = f.index + f.text.length;
29
+ if (kept.some((k) => s < k.index + k.text.length && e > k.index))
30
+ continue;
31
+ kept.push(f);
32
+ }
33
+ return kept;
34
+ }
35
+ /**
36
+ * 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
37
+ *
38
+ * @example
39
+ * parseAll('내일 10시 아니면 모레 2시');
40
+ * // → [{ date: 내일 10:00, text: '내일 10시', ... }, { date: 모레 14:00, text: '모레 2시', ... }]
41
+ *
42
+ * @example
43
+ * parseAll('11월 1일부터 11월 3일까지');
44
+ * // → [{ date: 11-01 00:00, end: 11-03 00:00, ... }] "A부터 B까지"는 하나의 구간으로 합쳐진다
45
+ */
46
+ export function parseAll(text, options) {
47
+ const opts = withDefaults(options);
48
+ const frags = dedupe(PARSERS.flatMap((p) => p(text)));
49
+ const groups = groupFragments(frags, text);
50
+ const results = [];
51
+ const comps = [];
52
+ for (const g of groups) {
53
+ const last = g[g.length - 1];
54
+ const tail = text.slice(last.index + last.text.length);
55
+ const until = UNTIL.exec(tail) ?? WHOLE.exec(tail);
56
+ if (until && until[0].includes('내내'))
57
+ last.components.whole = true;
58
+ else if (until)
59
+ last.components.isEnd = true;
60
+ if (FROM.test(tail))
61
+ last.components.isStart = true;
62
+ const r = resolve(g, opts);
63
+ if (!r)
64
+ continue;
65
+ let end = r._end;
66
+ if (until)
67
+ end += until[0].length;
68
+ delete r._end;
69
+ r.index = g[0].index;
70
+ r.text = text.slice(g[0].index, end).trim();
71
+ results.push(r);
72
+ comps.push(merge(g.map((f) => f.components)));
73
+ }
74
+ // "A부터 B까지", "A~B" → 하나의 구간으로 합침
75
+ const merged = [];
76
+ for (let i = 0; i < results.length; i++) {
77
+ const a = results[i], b = results[i + 1];
78
+ const from = FROM.exec(text.slice(a.index + a.text.length));
79
+ // "~"는 바로 뒤에 B가 붙어 있을 때만. "부터"는 B가 "까지"로 끝날 때만
80
+ const tilde = from?.[1] === '~' && b?.index === a.index + a.text.length + from[0].length;
81
+ if (b && from && (b.end || tilde)) {
82
+ const bc = comps[i + 1];
83
+ let end = b.end ?? b.date;
84
+ if (isTimeOnly(bc)) {
85
+ end = alignEnd(a.date, bc);
86
+ }
87
+ else {
88
+ // "11월 1일부터 3일까지": 끝에 일만 있으면 시작의 연월을 이어받는다
89
+ // ("11월 28일부터 2일까지"처럼 시작보다 이르면 다음 달)
90
+ if (isDayOnly(bc)) {
91
+ const y = a.date.getFullYear(), m0 = a.date.getMonth();
92
+ end = new Date(y, m0, bc.day, end.getHours(), end.getMinutes());
93
+ if (end < a.date)
94
+ end = new Date(y, m0 + 1, bc.day, end.getHours(), end.getMinutes());
95
+ }
96
+ // "월요일부터 금요일까지": 끝 요일이 시작보다 앞이면 다음 주
97
+ if (isWeekdayOnly(bc) && end < a.date)
98
+ end.setDate(end.getDate() + 7);
99
+ if (!b.end && !b.hasTime)
100
+ end = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 23, 59);
101
+ }
102
+ merged.push({ ...a, end, text: text.slice(a.index, b.index + b.text.length).trim() });
103
+ i++;
104
+ }
105
+ else {
106
+ merged.push(a);
107
+ }
108
+ }
109
+ return merged;
110
+ }
111
+ /**
112
+ * 문장에서 첫 번째 날짜/시간 표현을 찾는다. 없으면 null.
113
+ *
114
+ * @example
115
+ * parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?');
116
+ * // → { date: 다음주 수요일 19:00, text: '다음주 수요일 저녁 7시', index: 3, hasTime: true }
117
+ *
118
+ * @example
119
+ * parse('이번달 말까지 보내주세요');
120
+ * // → { date: 기준 시각, end: 이번달 마지막날 23:59, text: '이번달 말까지', hasTime: false, ... }
121
+ *
122
+ * @example
123
+ * parse('오후 3시', { referenceDate: new Date(2026, 9, 30, 14) });
124
+ * // → 2026-10-30 15:00 (시간만 있으면 오늘, 이미 지났으면 내일)
125
+ */
126
+ export function parse(text, options) {
127
+ return parseAll(text, options)[0] ?? null;
128
+ }
129
+ /**
130
+ * parse()의 date만 돌려주는 단축형. 구간이면 시작 시각.
131
+ *
132
+ * @example
133
+ * parseDate('다음주 화요일 오후 3시'); // → Date
134
+ * parseDate('아무 날짜도 없음'); // → null
135
+ */
136
+ export function parseDate(text, options) {
137
+ return parse(text, options)?.date ?? null;
138
+ }
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 절대 날짜 표현 */
3
+ export declare const absoluteDate: Parser;
@@ -0,0 +1,87 @@
1
+ import { parseKoreanNumber } from '../util/number.js';
2
+ // 2026년 11월 5일 / 26년 11월 5일 / 11월 5일 / 11/5 / 2026-11-05 / 26.11.5 / 11.5(월일로 해석)
3
+ const FULL = /(?:(?<!\d)(\d{4}|\d{2})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일(?!간|\s*동안)/g;
4
+ const ISO = /(?<![\d/.-])(\d{4}|\d{2})[-./]\s*(\d{1,2})[-./]\s*(\d{1,2})(?!\d)/g;
5
+ const SLASH = /(?<![\d/.-])(\d{1,2})\s*[/.]\s*(\d{1,2})(?![\d/.:])/g;
6
+ // "십일월 오일", "유월 이십오일" (오탐 방지를 위해 월과 일이 모두 있을 때만)
7
+ const HANGUL = /(?<![가-힣])(유|시|[일이삼사오육칠팔구십]{1,2})월\s*([일이삼사오육칠팔구십]{1,3})일(?!요일)/g;
8
+ const HANGUL_MONTH = { 유: 6, 시: 10 };
9
+ // "2027년" 단독 (월이 뒤따르지 않을 때). "26년"은 "26년 동안" 오탐이 있어 4자리만
10
+ const YEAR_ONLY = /(?<!\d)((?:19|20)\d{2})\s*년(?!\s*\d{1,2}\s*월)/g;
11
+ // "11월" 단독, "5일" 단독
12
+ const MONTH_ONLY = /(?<![\d가-힣])(\d{1,2})\s*월(?!\s*\d{1,2}\s*일|생|세)/g;
13
+ // "5일간", "3일 동안", "3박 4일"은 기간이지 날짜가 아니다
14
+ const DAY_ONLY = /(?<!\d)(?<!\d\s*박\s*)(\d{1,2})\s*일(?!요일|간|장|째|차|분|\s*동안|\s*연속)/g;
15
+ /** 두 자리 연도 확장: 00~69 → 20xx, 70~99 → 19xx */
16
+ const expandYear = (y) => {
17
+ const n = +y;
18
+ if (y.length !== 2)
19
+ return n;
20
+ return n < 70 ? 2000 + n : 1900 + n;
21
+ };
22
+ /** 절대 날짜 표현 */
23
+ export const absoluteDate = (text) => {
24
+ const out = [];
25
+ const taken = [];
26
+ const take = (i, len) => {
27
+ if (taken.some(([s, e]) => i < e && i + len > s))
28
+ return false;
29
+ taken.push([i, i + len]);
30
+ return true;
31
+ };
32
+ for (const m of text.matchAll(ISO)) {
33
+ const month = +m[2], day = +m[3];
34
+ if (month < 1 || month > 12 || day < 1 || day > 31)
35
+ continue;
36
+ if (!take(m.index, m[0].length))
37
+ continue;
38
+ out.push({ text: m[0], index: m.index, components: { year: expandYear(m[1]), month, day } });
39
+ }
40
+ for (const m of text.matchAll(FULL)) {
41
+ if (!take(m.index, m[0].length))
42
+ continue;
43
+ const c = { month: +m[2], day: +m[3] };
44
+ if (m[1])
45
+ c.year = expandYear(m[1]);
46
+ out.push({ text: m[0], index: m.index, components: c });
47
+ }
48
+ for (const m of text.matchAll(HANGUL)) {
49
+ const month = HANGUL_MONTH[m[1]] ?? parseKoreanNumber(m[1]);
50
+ const day = parseKoreanNumber(m[2]);
51
+ if (month == null || day == null || month < 1 || month > 12 || day < 1 || day > 31)
52
+ continue;
53
+ if (!take(m.index, m[0].length))
54
+ continue;
55
+ out.push({ text: m[0], index: m.index, components: { month, day } });
56
+ }
57
+ for (const m of text.matchAll(SLASH)) {
58
+ if (!take(m.index, m[0].length))
59
+ continue;
60
+ const month = +m[1], day = +m[2];
61
+ if (month < 1 || month > 12 || day < 1 || day > 31)
62
+ continue;
63
+ out.push({ text: m[0], index: m.index, components: { month, day } });
64
+ }
65
+ for (const m of text.matchAll(YEAR_ONLY)) {
66
+ if (!take(m.index, m[0].length))
67
+ continue;
68
+ out.push({ text: m[0], index: m.index, components: { year: +m[1] } });
69
+ }
70
+ for (const m of text.matchAll(MONTH_ONLY)) {
71
+ if (!take(m.index, m[0].length))
72
+ continue;
73
+ const month = +m[1];
74
+ if (month < 1 || month > 12)
75
+ continue;
76
+ out.push({ text: m[0], index: m.index, components: { month } });
77
+ }
78
+ for (const m of text.matchAll(DAY_ONLY)) {
79
+ if (!take(m.index, m[0].length))
80
+ continue;
81
+ const day = +m[1];
82
+ if (day < 1 || day > 31)
83
+ continue;
84
+ out.push({ text: m[0], index: m.index, components: { day } });
85
+ }
86
+ return out;
87
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 기간 표현. 날짜를 계산하지 않고 길이만 뽑는다. "A부터 N일간"으로 쓰일 때 resolve가 end를 만든다. */
3
+ export declare const duration: Parser;
@@ -0,0 +1,39 @@
1
+ import { parseKoreanNumber } from '../util/number.js';
2
+ // "3일간", "일주일 동안", "2주간", "한 달간", "2시간 동안", "90분간", "이틀간"
3
+ const RE = /(?:(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*(일|주|주일|달|개월|시간|분)|(보름|열흘|이틀|사흘|나흘|닷새|엿새|이레|여드레|아흐레))\s*(간|동안)/g;
4
+ // "3박 4일" → 4일
5
+ const NIGHTS = /(\d+)\s*박\s*(\d+)\s*일/g;
6
+ /** 기간 표현. 날짜를 계산하지 않고 길이만 뽑는다. "A부터 N일간"으로 쓰일 때 resolve가 end를 만든다. */
7
+ export const duration = (text) => {
8
+ const out = [];
9
+ for (const m of text.matchAll(NIGHTS)) {
10
+ out.push({ text: m[0], index: m.index, components: { durationDays: +m[2] } });
11
+ }
12
+ for (const m of text.matchAll(RE)) {
13
+ const n = parseKoreanNumber(m[3] ?? m[1]);
14
+ if (n == null)
15
+ continue;
16
+ const c = {};
17
+ switch (m[3] ? '일' : m[2]) {
18
+ case '일':
19
+ c.durationDays = n;
20
+ break;
21
+ case '주':
22
+ case '주일':
23
+ c.durationDays = n * 7;
24
+ break;
25
+ case '달':
26
+ case '개월':
27
+ c.durationMonths = n;
28
+ break;
29
+ case '시간':
30
+ c.durationMinutes = n * 60;
31
+ break;
32
+ case '분':
33
+ c.durationMinutes = n;
34
+ break;
35
+ }
36
+ out.push({ text: m[0], index: m.index, components: c });
37
+ }
38
+ return out;
39
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** "N일 뒤 / N주 후 / N달 전 / N시간 뒤 / N분 후" */
3
+ export declare const offset: Parser;
@@ -0,0 +1,57 @@
1
+ import { parseKoreanNumber } from '../util/number.js';
2
+ // "3일 뒤", "2주 후", "한 달 전", "세 시간 뒤", "30분 후", "열흘 뒤", "이틀 뒤", "1년 뒤", "30분 내로"
3
+ const RE = /(?:(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*(일|주|주일|달|개월|년|시간|분)|(보름|열흘|이틀|사흘|나흘|닷새|엿새|이레|여드레|아흐레))\s*(뒤|후|전|이내|안에|안으로|내로|이후)/g;
4
+ // "한 시간 반 뒤", "1시간 30분 후", "2시간반 전"
5
+ const HOUR_MIN = /(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*시간\s*(?:(반)|(\d+)\s*분)\s*(뒤|후|전|이내|안에|이후)/g;
6
+ /** "N일 뒤 / N주 후 / N달 전 / N시간 뒤 / N분 후" */
7
+ export const offset = (text) => {
8
+ const out = [];
9
+ const taken = [];
10
+ for (const m of text.matchAll(HOUR_MIN)) {
11
+ const h = parseKoreanNumber(m[1]);
12
+ if (h == null)
13
+ continue;
14
+ const sign = m[4] === '전' ? -1 : 1;
15
+ const c = { hourOffset: sign * h, minuteOffset: sign * (m[2] ? 30 : +m[3]) };
16
+ if (m[4] === '이내' || m[4] === '안에')
17
+ c.isEnd = true;
18
+ out.push({ text: m[0], index: m.index, components: c });
19
+ taken.push([m.index, m.index + m[0].length]);
20
+ }
21
+ for (const m of text.matchAll(RE)) {
22
+ if (taken.some(([s, e]) => m.index < e && m.index + m[0].length > s))
23
+ continue;
24
+ const n = parseKoreanNumber(m[3] ?? m[1]);
25
+ if (n == null)
26
+ continue;
27
+ const sign = m[4] === '전' ? -1 : 1;
28
+ const c = {};
29
+ switch (m[3] ? '일' : m[2]) {
30
+ case '일':
31
+ c.dayOffset = sign * n;
32
+ break;
33
+ case '주':
34
+ case '주일':
35
+ c.dayOffset = sign * n * 7;
36
+ break;
37
+ case '달':
38
+ case '개월':
39
+ c.monthOffset = sign * n;
40
+ break;
41
+ case '년':
42
+ c.yearOffset = sign * n;
43
+ c.sameDay = true;
44
+ break;
45
+ case '시간':
46
+ c.hourOffset = sign * n;
47
+ break;
48
+ case '분':
49
+ c.minuteOffset = sign * n;
50
+ break;
51
+ }
52
+ if (['이내', '안에', '안으로', '내로'].includes(m[4]))
53
+ c.isEnd = true;
54
+ out.push({ text: m[0], index: m.index, components: c });
55
+ }
56
+ return out;
57
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 오늘/내일/모레/글피/어제/그저께 */
3
+ export declare const relativeDay: Parser;
@@ -0,0 +1,22 @@
1
+ const WORDS = {
2
+ 그저께: -2,
3
+ 내일모레: 2,
4
+ 낼모레: 2,
5
+ 낼: 1,
6
+ 그제: -2,
7
+ 어제: -1,
8
+ 오늘: 0,
9
+ 내일: 1,
10
+ 모레: 2,
11
+ 글피: 3,
12
+ };
13
+ // "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
14
+ const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
15
+ /** 오늘/내일/모레/글피/어제/그저께 */
16
+ export const relativeDay = (text) => {
17
+ const out = [];
18
+ for (const m of text.matchAll(RE)) {
19
+ out.push({ text: m[0], index: m.index, components: { dayOffset: WORDS[m[1]] } });
20
+ }
21
+ return out;
22
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 이번달/다음달 (초/중순/말) */
3
+ export declare const relativeMonth: Parser;
@@ -0,0 +1,26 @@
1
+ const WORDS = { 지난: -1, 저번: -1, 이번: 0, 다음: 1, 다다음: 2, 담: 1 };
2
+ const PART = { 초: 'start', 중순: 'middle', 말: 'end' };
3
+ // "이번달 말", "다음 달 초", "이번달 중순", "다음달", "다음달 이맘때", "월초", "월말"
4
+ const RE = /(지난|저번|이번|다다음|다음|담)\s*달\s*(초|중순|말|이맘때)?|(?<![가-힣])월(초|말)/g;
5
+ // "11월 말", "12월 초" (absoluteDate의 MONTH_ONLY와 겹치지만 part가 있는 경우 우선)
6
+ const ABS_PART = /(?<![\d가-힣])(\d{1,2})\s*월\s*(초|중순|말)/g;
7
+ /** 이번달/다음달 (초/중순/말) */
8
+ export const relativeMonth = (text) => {
9
+ const out = [];
10
+ for (const m of text.matchAll(RE)) {
11
+ if (m[3]) {
12
+ out.push({ text: m[0], index: m.index, components: { monthOffset: 0, monthPart: PART[m[3]] } });
13
+ continue;
14
+ }
15
+ const c = { monthOffset: WORDS[m[1]] };
16
+ if (m[2] === '이맘때')
17
+ c.sameDay = true;
18
+ else if (m[2])
19
+ c.monthPart = PART[m[2]];
20
+ out.push({ text: m[0], index: m.index, components: c });
21
+ }
22
+ for (const m of text.matchAll(ABS_PART)) {
23
+ out.push({ text: m[0], index: m.index, components: { month: +m[1], monthPart: PART[m[2]] } });
24
+ }
25
+ return out;
26
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 올해/내년/작년 (초/말) */
3
+ export declare const relativeYear: Parser;
@@ -0,0 +1,26 @@
1
+ const WORDS = {
2
+ 재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 내후년: 2,
3
+ };
4
+ // "올해 말", "내년 초", "작년", "재작년", "내후년", "지난해", "내년 이맘때", "연말", "연초"
5
+ const RE = /(재작년|작년|지난해|올해|금년|내후년|내년|명년)(?:\s*(초|말|이맘때))?|(?<![가-힣])(연말(?!정산)|연초)/g;
6
+ /** 올해/내년/작년 (초/말) */
7
+ export const relativeYear = (text) => {
8
+ const out = [];
9
+ for (const m of text.matchAll(RE)) {
10
+ if (m[3]) {
11
+ // "연초"는 연도가 없으므로 지난 월일 → 내년 규칙(13)을 따른다
12
+ const c = m[3] === '연초' ? { month: 1, monthPart: 'start' } : { month: 12, monthPart: 'end' };
13
+ out.push({ text: m[0], index: m.index, components: c });
14
+ continue;
15
+ }
16
+ const c = { yearOffset: WORDS[m[1]] };
17
+ if (m[2] === '초')
18
+ Object.assign(c, { month: 1, monthPart: 'start' });
19
+ if (m[2] === '말')
20
+ Object.assign(c, { month: 12, monthPart: 'end' });
21
+ if (m[2] === '이맘때')
22
+ c.sameDay = true;
23
+ out.push({ text: m[0], index: m.index, components: c });
24
+ }
25
+ return out;
26
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 시각 표현 */
3
+ export declare const time: Parser;