ko-chrono 0.2.0 → 0.3.1
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 +38 -8
- package/dist/cjs/event.d.ts +3 -1
- package/dist/cjs/event.js +2 -0
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +3 -0
- package/dist/cjs/parsers/holiday.js +8 -0
- package/dist/cjs/parsers/relativeDay.js +7 -2
- package/dist/cjs/parsers/relativeMonth.js +9 -0
- package/dist/cjs/parsers/relativeYear.js +2 -2
- package/dist/cjs/parsers/time.js +4 -1
- package/dist/cjs/parsers/weekday.js +17 -0
- package/dist/cjs/resolve.js +42 -10
- package/dist/cjs/types.d.ts +20 -0
- package/dist/cjs/util/lunarHolidays.d.ts +10 -2
- package/dist/cjs/util/lunarHolidays.js +77 -11
- package/dist/event.d.ts +3 -1
- package/dist/event.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -0
- package/dist/parsers/holiday.js +8 -0
- package/dist/parsers/relativeDay.js +7 -2
- package/dist/parsers/relativeMonth.js +9 -0
- package/dist/parsers/relativeYear.js +2 -2
- package/dist/parsers/time.js +4 -1
- package/dist/parsers/weekday.js +17 -0
- package/dist/resolve.js +43 -11
- package/dist/types.d.ts +20 -0
- package/dist/util/lunarHolidays.d.ts +10 -2
- package/dist/util/lunarHolidays.js +73 -10
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
<img alt="'그럼 다음주 화요일 오후 3시에 볼까?'에서 '다음주 화요일 오후 3시'를 찾아 2026-11-03 15:00으로 바꾼다" src="./docs/banner-light.svg" width="760">
|
|
15
15
|
</picture>
|
|
16
16
|
|
|
17
|
+
**[▶ 브라우저에서 바로 해 보기](https://jwn7.github.io/ko-chrono/)**
|
|
18
|
+
|
|
17
19
|
[설치](#설치) · [사용법](#사용법) · [지원 표현](#지원-표현) · [해석 규칙](#해석-규칙) · [예제](#예제) · [문장 제보](https://github.com/jwn7/ko-chrono/issues/new?template=sentence.yml)
|
|
18
20
|
|
|
19
21
|
</div>
|
|
@@ -88,6 +90,26 @@ parseEvent('우유 사기');
|
|
|
88
90
|
// { title: '우유 사기', date: null, hasTime: false }
|
|
89
91
|
```
|
|
90
92
|
|
|
93
|
+
### 애매한 해석: `ambiguity`
|
|
94
|
+
|
|
95
|
+
"3시"가 오후인지 새벽인지처럼 해석이 갈릴 수 있으면, 기본 해석은 그대로 두고 다른 후보를 함께 알려 줍니다.
|
|
96
|
+
사용자에게 되묻거나, 이런 문장만 LLM에 넘길 때 쓰면 됩니다.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const r = parse('3시에 보자');
|
|
100
|
+
// r.date → 오늘 15:00 (오전/오후가 없는 1~6시는 오후로)
|
|
101
|
+
// r.ambiguity → { reason: 'meridiem', alternatives: [내일 03:00] }
|
|
102
|
+
|
|
103
|
+
if (!r) askLLM(text); // 날짜를 못 찾음
|
|
104
|
+
else if (r.ambiguity?.alternatives.length) confirm(r); // "오후 3시 맞으세요?"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| `reason` | 언제 | `alternatives` |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `meridiem` | 오전/오후 없는 1\~12시 ("3시", "내일 7시") | 반대쪽 12시간 |
|
|
110
|
+
| `next-weekday` | "다음 토요일"이 이번 주 토요일일 수도, 다음 주 토요일일 수도 | 다른 쪽 날짜 |
|
|
111
|
+
| `approximate-time` | "저녁"(19시), "아침"(8시)처럼 관례로 정한 시각 | 없음 |
|
|
112
|
+
|
|
91
113
|
### 반복 일정
|
|
92
114
|
|
|
93
115
|
`recurrence.rrule`은 [RFC 5545 RRULE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html)이라 Google·Apple 캘린더, iCal에 그대로 넣을 수 있습니다. `date`는 기준 시각 이후 첫 발생입니다.
|
|
@@ -121,6 +143,7 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
|
|
|
121
143
|
| `index` | 문장 안에서의 위치 |
|
|
122
144
|
| `hasTime` | 시각까지 말했는지. `false`면 `date`는 그날 `defaultHour`시 |
|
|
123
145
|
| `recurrence` | 반복 일정이면 규칙 (`freq`, `interval`, `byWeekday`, …, `rrule`) |
|
|
146
|
+
| `ambiguity` | 해석이 갈릴 수 있으면 `{ reason, alternatives }` |
|
|
124
147
|
|
|
125
148
|
### 옵션
|
|
126
149
|
|
|
@@ -154,6 +177,8 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
|
|
|
154
177
|
| 마감 | \~까지 · \~안에 · \~중으로 · 이번 주 안에 · 오전 중으로 · 퇴근 전까지 |
|
|
155
178
|
| 구간 | A부터 B까지 · 3\~5시 · 월\~금 · 오늘부터 3일간 · 3박 4일 · 이번 주 내내 |
|
|
156
179
|
| 공휴일·명절 | 크리스마스 · 빼빼로데이 · 광복절 전날 · 설날 · 설 연휴 · 추석 전날 · 부처님 오신 날 |
|
|
180
|
+
| 음력 | 음력 3월 3일 · 음력 윤5월 1일 · 2028년 음력 9월 9일 |
|
|
181
|
+
| 업무용 한자어 | 금일 · 명일 · 익일 · 작일 · 금주 · 차주 · 전주 수요일 · 익월 초 · 금월 말까지 · 익년 |
|
|
157
182
|
| 반복 | 매일 · 매주 월수금 · 매주 월\~금 · 격주 · 평일마다 · 매달 15일 · 매월 말일 · 매달 마지막 주 금요일 · 매년 3월 1일 · 2시간마다 |
|
|
158
183
|
| 문장 | 띄어쓰기 생략 · 조사(은·는·도) · 어림말(쯤·경·께) · 여러 개 추출 |
|
|
159
184
|
|
|
@@ -195,12 +220,14 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
|
|
|
195
220
|
- "오늘부터 3일간" → `end`는 3일째 되는 날 23:59
|
|
196
221
|
|
|
197
222
|
**명절**
|
|
223
|
+
- "음력 3월 3일"처럼 음력이라고 말한 날짜도 같은 자료로 변환합니다
|
|
198
224
|
- 설날·추석·부처님 오신 날은 [한국천문연구원](https://astro.kasi.re.kr/life/post/calendarData) 자료가 있는 연도(현재 2025\~2028)만. 그 뒤는 추측하지 않고 `null`
|
|
199
225
|
- "설 연휴", "추석 연휴" = 전날부터 다음날까지 3일
|
|
200
226
|
|
|
201
227
|
**잡지 않는 것**
|
|
202
228
|
- 없는 날짜 (2월 30일, 11월 31일), 날짜와 요일이 안 맞는 경우 ("11월 5일 수요일")
|
|
203
229
|
- 기간만 있는 표현 ("3일간", "3박 4일"), 날짜가 아닌 말 ("3일 연속", "1월생", "내가 낼게")
|
|
230
|
+
- 한자어가 다른 뜻일 때: "금주 결심"(禁酒), "전주 여행"(도시), "차주에게 연락"(車主), "전월세"
|
|
204
231
|
- "내일 30분 후"처럼 날짜와 상대 시간이 섞이면 두 개의 결과로 나눔
|
|
205
232
|
|
|
206
233
|
</details>
|
|
@@ -213,7 +240,7 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
|
|
|
213
240
|
|---|---|---|
|
|
214
241
|
| ✅ | [**할 일 빠른 입력**](./examples/quick-add.mjs)<br>`parseEvent`로 "다음주 금요일까지 보고서 제출" → 제목 "보고서 제출" + 마감 11/6 23:59 | `node examples/quick-add.mjs "..."` |
|
|
215
242
|
| 💬 | [**Slack 봇**](./examples/slack-bot)<br>채널 메시지에서 날짜를 찾아 스레드로 알려줌 (UTC 서버 + `timezone`) | `node examples/slack-bot/try.mjs` |
|
|
216
|
-
| 🧪 | [**Playground**](
|
|
243
|
+
| 🧪 | [**Playground**](https://jwn7.github.io/ko-chrono/)<br>브라우저에서 직접 입력해 보기 (문장을 넣으면 공유 링크가 생김) | [jwn7.github.io/ko-chrono](https://jwn7.github.io/ko-chrono/) · 로컬은 `npm run playground` |
|
|
217
244
|
|
|
218
245
|
<br>
|
|
219
246
|
|
|
@@ -221,16 +248,18 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
|
|
|
221
248
|
|
|
222
249
|
날짜를 LLM에 물어볼 수도 있지만, 정해진 규칙으로 풀리는 문제라면 이쪽이 빠르고 공짜이고 매번 같은 답을 냅니다.
|
|
223
250
|
|
|
224
|
-
|
|
251
|
+
메신저 문장 112개로 측정한 결과 ([자세히](./bench/RESULTS.md), Gemini Flash-Lite **무료 티어**, 2026-10-31):
|
|
252
|
+
|
|
253
|
+
| | ko-chrono | LLM API (Gemini Flash-Lite) |
|
|
225
254
|
|---|---|---|
|
|
226
|
-
| 지연 | 문장당
|
|
227
|
-
| 비용 | **0** | 토큰당 과금 |
|
|
255
|
+
| 지연 | 문장당 **0.017ms** | 문장당 **987ms** (네트워크만 40ms 이상) |
|
|
256
|
+
| 비용 | **0** | 토큰당 과금 (무료 티어는 분당·일일 한도) |
|
|
228
257
|
| 같은 문장 → 같은 답 | **항상** | 보장 안 됨 |
|
|
229
258
|
| 오프라인 · 브라우저 | **됨** | 안 됨 |
|
|
259
|
+
| 시제·문맥 ("깨서 못 잤어") | 모름 | 앎 |
|
|
230
260
|
| 처음 보는 표현 | 못 함 (`null`) | 대체로 함 |
|
|
231
261
|
|
|
232
|
-
같은
|
|
233
|
-
규칙으로 풀리는 날짜는 ko-chrono로, 그 밖의 자유로운 문장만 LLM으로 보내는 조합을 권합니다.
|
|
262
|
+
유료 티어나 다른 모델이면 LLM 쪽이 더 빠를 수 있지만, 네트워크 왕복만으로도 ko-chrono보다 수천 배 느립니다. 정확도는 비슷한 수준이고 차이의 절반은 "저녁이 몇 시냐" 같은 관례 차이였습니다. 규칙으로 풀리는 날짜는 ko-chrono로, `null`이 나오거나 `ambiguity`가 있는 문장만 LLM으로 보내는 조합을 권합니다.
|
|
234
263
|
|
|
235
264
|
<br>
|
|
236
265
|
|
|
@@ -239,8 +268,9 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
|
|
|
239
268
|
- [x] 반복 일정 (매주 월요일 9시, 격주) → RRULE
|
|
240
269
|
- [x] 제목 분리 (`parseEvent`)
|
|
241
270
|
- [x] 명절·공휴일 (설 연휴, 추석 전날, 크리스마스) — 음력 명절은 한국천문연구원 자료가 있는 2025\~2028년
|
|
242
|
-
- [
|
|
243
|
-
- [
|
|
271
|
+
- [x] 일반 음력 날짜 (음력 3월 3일) — 한국천문연구원 자료가 있는 2025\~2028년
|
|
272
|
+
- [x] 모호성 정보 (`ambiguity`)
|
|
273
|
+
- [ ] chrono-node `ko` 로케일 기여 — 첫 PR 분량 준비됨 ([contrib/chrono-ko](./contrib/chrono-ko))
|
|
244
274
|
|
|
245
275
|
## 기여
|
|
246
276
|
|
package/dist/cjs/event.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ParseResult, Recurrence } from './types.js';
|
|
1
|
+
import type { Ambiguity, ParseResult, Recurrence } from './types.js';
|
|
2
2
|
/** parseEvent()의 결과: 할 일·일정 한 줄을 제목과 시점으로 나눈 것 */
|
|
3
3
|
export interface ParsedEvent {
|
|
4
4
|
/** 날짜 부분을 뺀 나머지 ("내일 3시 치과" → "치과"). 없으면 '' */
|
|
@@ -10,6 +10,8 @@ export interface ParsedEvent {
|
|
|
10
10
|
hasTime: boolean;
|
|
11
11
|
/** 반복 일정이면 그 규칙 */
|
|
12
12
|
recurrence?: Recurrence;
|
|
13
|
+
/** 해석이 갈릴 수 있으면 그 이유와 다른 후보 ("3시" → 15시, 후보 3시) */
|
|
14
|
+
ambiguity?: Ambiguity;
|
|
13
15
|
}
|
|
14
16
|
/**
|
|
15
17
|
* 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
|
package/dist/cjs/event.js
CHANGED
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ParseOptions, ParseResult } from './types.js';
|
|
2
2
|
import { type ParsedEvent } from './event.js';
|
|
3
|
-
export type { Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
|
|
3
|
+
export type { Ambiguity, Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
|
|
4
4
|
export type { ParsedEvent } from './event.js';
|
|
5
5
|
/**
|
|
6
6
|
* 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
|
package/dist/cjs/index.js
CHANGED
|
@@ -64,6 +64,9 @@ function parseAll(text, options) {
|
|
|
64
64
|
const out = { ...r, date: (0, timezone_js_1.fromWallClock)(r.date, timezone) };
|
|
65
65
|
if (r.end)
|
|
66
66
|
out.end = (0, timezone_js_1.fromWallClock)(r.end, timezone);
|
|
67
|
+
if (r.ambiguity) {
|
|
68
|
+
out.ambiguity = { ...r.ambiguity, alternatives: r.ambiguity.alternatives.map((a) => (0, timezone_js_1.fromWallClock)(a, timezone)) };
|
|
69
|
+
}
|
|
67
70
|
return out;
|
|
68
71
|
});
|
|
69
72
|
}
|
|
@@ -31,6 +31,8 @@ const MODIFIER = '(?:\\s*(전날|다음\\s*날|이튿날|당일))?';
|
|
|
31
31
|
const FIXED_RE = new RegExp(`(${Object.keys(FIXED).map((k) => k.replace(/ /g, '\\s*')).join('|')})${MODIFIER}`, 'g');
|
|
32
32
|
// "설날", "구정", "설 연휴", "추석 전날", "한가위", "부처님 오신 날". "설"만은 "설명", "설거지"와 겹쳐서 뒤에 연휴·전날 등이 올 때만
|
|
33
33
|
const LUNAR_RE = /(설날|구정|설(?=\s*(?:연휴|전날|다음\s*날|당일))|추석|한가위|부처님\s*오신\s*날|석가\s*탄신일|석탄일|초파일)(?:\s*(연휴))?(?:\s*(전날|다음\s*날|이튿날|당일))?/g;
|
|
34
|
+
// "음력 3월 3일", "음력 윤5월 1일" (음력이라고 말한 경우만)
|
|
35
|
+
const LUNAR_DATE_RE = /음력\s*(윤\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;
|
|
34
36
|
const fixedKey = (name) => Object.keys(FIXED).find((k) => k.replace(/ /g, '') === name.replace(/\s/g, ''));
|
|
35
37
|
const shiftOf = (m) => (m ? SHIFT[m.replace(/\s/g, '')] : 0);
|
|
36
38
|
/** 공휴일·기념일·명절. 음력 명절의 양력 날짜는 resolve가 표에서 찾는다 */
|
|
@@ -44,6 +46,12 @@ const holiday = (text) => {
|
|
|
44
46
|
c.dayOffset = shift;
|
|
45
47
|
out.push({ text: m[0], index: m.index, components: c });
|
|
46
48
|
}
|
|
49
|
+
for (const m of text.matchAll(LUNAR_DATE_RE)) {
|
|
50
|
+
const month = +m[2], day = +m[3];
|
|
51
|
+
if (month < 1 || month > 12 || day < 1 || day > 30)
|
|
52
|
+
continue;
|
|
53
|
+
out.push({ text: m[0], index: m.index, components: { lunarDate: { month, day, leap: !!m[1] } } });
|
|
54
|
+
}
|
|
47
55
|
for (const m of text.matchAll(LUNAR_RE)) {
|
|
48
56
|
const name = m[1].replace(/\s/g, '');
|
|
49
57
|
const lunar = name === '설날' || name === '구정' || name === '설' ? 'seollal'
|
|
@@ -3,6 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.relativeDay = void 0;
|
|
4
4
|
const WORDS = {
|
|
5
5
|
그저께: -2,
|
|
6
|
+
// 업무용 한자어 (규칙 33)
|
|
7
|
+
금일: 0,
|
|
8
|
+
명일: 1,
|
|
9
|
+
익일: 1,
|
|
10
|
+
작일: -1,
|
|
6
11
|
내일모레: 2,
|
|
7
12
|
낼모레: 2,
|
|
8
13
|
낼: 1,
|
|
@@ -13,8 +18,8 @@ const WORDS = {
|
|
|
13
18
|
모레: 2,
|
|
14
19
|
글피: 3,
|
|
15
20
|
};
|
|
16
|
-
// "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
|
|
17
|
-
const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
|
|
21
|
+
// "금일", "익일 배송", "작일" (업무용 한자어). "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
|
|
22
|
+
const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])(?:금일|명일|익일|작일)|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
|
|
18
23
|
/** 오늘/내일/모레/글피/어제/그저께 */
|
|
19
24
|
const relativeDay = (text) => {
|
|
20
25
|
const out = [];
|
|
@@ -7,6 +7,9 @@ const PART = { 초: 'start', 중순: 'middle', 말: 'end' };
|
|
|
7
7
|
const RE = /(지난|저번|이번|다다음|다음|담)\s*달\s*(초|중순|말|이맘때)?|(?<![가-힣])월(초|말)/g;
|
|
8
8
|
// "11월 말", "12월 초" (absoluteDate의 MONTH_ONLY와 겹치지만 part가 있는 경우 우선)
|
|
9
9
|
const ABS_PART = /(?<![\d가-힣])(\d{1,2})\s*월\s*(초|중순|말)/g;
|
|
10
|
+
// 업무용 한자어 달 (규칙 33): "익월 초", "금월 말까지", "전월 실적". "전월세"는 제외
|
|
11
|
+
const FORMAL_MONTH = /(?<![가-힣])(금|익|차|전)월(?!세)(?:\s*(초|중순|말))?/g;
|
|
12
|
+
const FORMAL_MONTH_OFFSET = { 금: 0, 익: 1, 차: 1, 전: -1 };
|
|
10
13
|
/** 이번달/다음달 (초/중순/말) */
|
|
11
14
|
const relativeMonth = (text) => {
|
|
12
15
|
const out = [];
|
|
@@ -22,6 +25,12 @@ const relativeMonth = (text) => {
|
|
|
22
25
|
c.monthPart = PART[m[2]];
|
|
23
26
|
out.push({ text: m[0], index: m.index, components: c });
|
|
24
27
|
}
|
|
28
|
+
for (const m of text.matchAll(FORMAL_MONTH)) {
|
|
29
|
+
const c = { monthOffset: FORMAL_MONTH_OFFSET[m[1]] };
|
|
30
|
+
if (m[2])
|
|
31
|
+
c.monthPart = PART[m[2]];
|
|
32
|
+
out.push({ text: m[0], index: m.index, components: c });
|
|
33
|
+
}
|
|
25
34
|
for (const m of text.matchAll(ABS_PART)) {
|
|
26
35
|
out.push({ text: m[0], index: m.index, components: { month: +m[1], monthPart: PART[m[2]] } });
|
|
27
36
|
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.relativeYear = void 0;
|
|
4
4
|
const WORDS = {
|
|
5
|
-
재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 내후년: 2,
|
|
5
|
+
재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 익년: 1, 내후년: 2,
|
|
6
6
|
};
|
|
7
7
|
// "올해 말", "내년 초", "작년", "재작년", "내후년", "지난해", "내년 이맘때", "연말", "연초"
|
|
8
|
-
const RE = /(
|
|
8
|
+
const RE = /(재작년|작년|지난해|올해|금년|내후년|내년|명년|익년)(?:\s*(초|말|이맘때))?|(?<![가-힣])(연말(?!정산)|연초)/g;
|
|
9
9
|
/** 올해/내년/작년 (초/말) */
|
|
10
10
|
const relativeYear = (text) => {
|
|
11
11
|
const out = [];
|
package/dist/cjs/parsers/time.js
CHANGED
|
@@ -134,7 +134,7 @@ const time = (text) => {
|
|
|
134
134
|
for (const m of text.matchAll(HALF_WORD)) {
|
|
135
135
|
if (!take(m.index, m[0].length))
|
|
136
136
|
continue;
|
|
137
|
-
out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true } });
|
|
137
|
+
out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true, approximate: true } });
|
|
138
138
|
}
|
|
139
139
|
for (const m of text.matchAll(WORD)) {
|
|
140
140
|
if (!take(m.index, m[0].length))
|
|
@@ -142,6 +142,9 @@ const time = (text) => {
|
|
|
142
142
|
const c = { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true };
|
|
143
143
|
if (m[1] === '자정')
|
|
144
144
|
c.midnight = true;
|
|
145
|
+
// 정오·자정은 정확한 시각, 나머지(아침·저녁·밤·점심·낮·퇴근)는 관례
|
|
146
|
+
else if (m[1] !== '정오')
|
|
147
|
+
c.approximate = true;
|
|
145
148
|
out.push({ text: m[0], index: m.index, components: c });
|
|
146
149
|
}
|
|
147
150
|
return out;
|
|
@@ -20,6 +20,13 @@ const NTH = new RegExp(`(?:(\\d{1,2})\\s*월\\s*)?(첫째|첫|둘째|셋째|넷
|
|
|
20
20
|
const NTH_NUM = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 다섯째: 5, 마지막: -1 };
|
|
21
21
|
// "2주 뒤 월요일", "3주 후 금요일" → N주 뒤의 그 주의 X요일 ("다다음주 월요일"과 같은 계산)
|
|
22
22
|
const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:뒤|후)\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
23
|
+
// 업무용 한자어 주 (규칙 33): "차주 금요일", "금주 월요일", "전주 수요일". 금주(禁酒)·전주(도시)·차주(車主)와
|
|
24
|
+
// 겹치므로 요일은 "요일/욜"까지 붙어야 하고, 요일 없이 쓸 때는 뒤에 날짜 말(내로·중으로·에·까지…)이 와야 한다.
|
|
25
|
+
// "전주"는 도시 이름과 너무 자주 겹쳐서 요일이 붙을 때만
|
|
26
|
+
const FORMAL_WEEK = { 전전: -2, 전: -1, 금: 0, 차: 1, 익: 1, 차차: 2 };
|
|
27
|
+
const FORMAL_WITH_DAY = new RegExp(`(?<![가-힣])(전전|차차|전|금|차|익)주\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
28
|
+
// "차주 중으로", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
|
|
29
|
+
const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)|에는|중으로|중에|내로|내|안에|안으로|까지|초|말|부터))|(금)주(?=\s*(?:중으로|중에|내로|내|안에|안으로|까지|초|말|부터)))/g;
|
|
23
30
|
// "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
|
|
24
31
|
const DIR = new RegExp(`(지난|저번|다음)\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
25
32
|
// "주말에 보자" ("이번 주말"은 위 WEEKEND가 먼저 잡는다)
|
|
@@ -35,6 +42,16 @@ const weekday = (text) => {
|
|
|
35
42
|
const out = [];
|
|
36
43
|
const taken = [];
|
|
37
44
|
const overlaps = (i, len) => taken.some(([s, e]) => i < e && i + len > s);
|
|
45
|
+
for (const m of text.matchAll(FORMAL_WITH_DAY)) {
|
|
46
|
+
out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1]], weekday: WEEKDAY[m[2]] } });
|
|
47
|
+
taken.push([m.index, m.index + m[0].length]);
|
|
48
|
+
}
|
|
49
|
+
for (const m of text.matchAll(FORMAL_WEEK_ONLY)) {
|
|
50
|
+
if (overlaps(m.index, m[0].length))
|
|
51
|
+
continue;
|
|
52
|
+
out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1] ?? m[2]] } });
|
|
53
|
+
taken.push([m.index, m.index + m[0].length]);
|
|
54
|
+
}
|
|
38
55
|
for (const m of text.matchAll(WEEKS_LATER)) {
|
|
39
56
|
const n = (0, number_js_1.parseKoreanNumber)(m[1]);
|
|
40
57
|
if (n == null)
|
package/dist/cjs/resolve.js
CHANGED
|
@@ -71,6 +71,7 @@ function merge(list) {
|
|
|
71
71
|
}
|
|
72
72
|
const DATE_KEYS = [
|
|
73
73
|
'year', 'yearOffset', 'month', 'day', 'dayOffset', 'weekOffset', 'weekday', 'monthOffset', 'monthPart',
|
|
74
|
+
'lunar', 'lunarDate',
|
|
74
75
|
];
|
|
75
76
|
/** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
|
|
76
77
|
function isTimeOnly(c) {
|
|
@@ -137,21 +138,24 @@ function resolve(group, opts) {
|
|
|
137
138
|
d.setSeconds(0, 0);
|
|
138
139
|
let dateSet = false;
|
|
139
140
|
const hasTime = c.hour !== undefined || c.hourOffset !== undefined || c.minuteOffset !== undefined;
|
|
140
|
-
// 0. 음력
|
|
141
|
-
//
|
|
141
|
+
// 0. 음력 명절·음력 날짜 (규칙 29, 32): 연도를 말했으면 그 음력 해, 아니면 (연휴까지) 지나지 않은 가장 가까운 것.
|
|
142
|
+
// 음력 12월은 양력으로 다음 해 1월이라 작년 음력부터 본다. 표에 없는 날짜는 null
|
|
142
143
|
const hasYear = c.year !== undefined || c.yearOffset !== undefined;
|
|
143
|
-
|
|
144
|
+
const isLunar = c.lunar !== undefined || c.lunarDate !== undefined;
|
|
145
|
+
if (isLunar) {
|
|
144
146
|
const said = c.year ?? (c.yearOffset !== undefined ? ref.getFullYear() + c.yearOffset : undefined);
|
|
145
147
|
const tail = (c.dayOffset ?? 0) + (c.durationDays ? c.durationDays - 1 : 0);
|
|
146
|
-
const
|
|
148
|
+
const y0 = ref.getFullYear();
|
|
149
|
+
const years = said !== undefined ? [said] : [y0 - 1, y0, y0 + 1, y0 + 2];
|
|
150
|
+
const solarOf = (y) => c.lunar ? (0, lunarHolidays_js_1.lunarHoliday)(y, c.lunar) : (0, lunarHolidays_js_1.lunarToSolar)(y, c.lunarDate.month, c.lunarDate.day, c.lunarDate.leap);
|
|
147
151
|
let found = null;
|
|
148
152
|
for (const y of years) {
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
151
|
-
|
|
152
|
-
const lastMoment = new Date(
|
|
153
|
+
const s = solarOf(y);
|
|
154
|
+
if (!s)
|
|
155
|
+
continue; // 그 해에 없는 날 ("음력 2월 30일", 윤달)
|
|
156
|
+
const lastMoment = new Date(s.getFullYear(), s.getMonth(), s.getDate() + tail, 23, 59);
|
|
153
157
|
if (said !== undefined || lastMoment >= ref) {
|
|
154
|
-
found =
|
|
158
|
+
found = s;
|
|
155
159
|
break;
|
|
156
160
|
}
|
|
157
161
|
}
|
|
@@ -161,7 +165,7 @@ function resolve(group, opts) {
|
|
|
161
165
|
dateSet = true;
|
|
162
166
|
}
|
|
163
167
|
// 1. 절대 날짜
|
|
164
|
-
if (!
|
|
168
|
+
if (!isLunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
|
|
165
169
|
let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
|
|
166
170
|
// "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
|
|
167
171
|
const m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
|
|
@@ -287,10 +291,13 @@ function resolve(group, opts) {
|
|
|
287
291
|
// 안전장치: 어떤 경로로든 계산이 깨진 날짜는 내보내지 않는다
|
|
288
292
|
if (Number.isNaN(d.getTime()))
|
|
289
293
|
return null;
|
|
294
|
+
const ambiguity = ambiguityOf(c, d, dateSet, opts);
|
|
290
295
|
const first = group[0];
|
|
291
296
|
const last = group[group.length - 1];
|
|
292
297
|
// text는 parseAll()에서 원문 slice로 채운다. _end는 그때 쓰는 내부 값.
|
|
293
298
|
const result = { date: d, text: '', index: first.index, hasTime };
|
|
299
|
+
if (ambiguity)
|
|
300
|
+
result.ambiguity = ambiguity;
|
|
294
301
|
result._end = last.index + last.text.length;
|
|
295
302
|
// "오늘부터 3일간", "3시부터 2시간 동안": 시작일을 포함한 기간의 끝
|
|
296
303
|
if (c.durationDays !== undefined || c.durationMonths !== undefined || c.durationMinutes !== undefined) {
|
|
@@ -405,6 +412,31 @@ function resolveRecurrence(group, c, opts) {
|
|
|
405
412
|
result._end = last.index + last.text.length;
|
|
406
413
|
return result;
|
|
407
414
|
}
|
|
415
|
+
/** 규칙 31: 해석이 갈릴 수 있는 지점과 다른 후보 */
|
|
416
|
+
function ambiguityOf(c, d, dateSet, opts) {
|
|
417
|
+
const ref = opts.referenceDate;
|
|
418
|
+
// 오전/오후 없는 1~12시: 반대쪽 12시간이 후보 (날짜가 없으면 규칙 4대로 다음 발생)
|
|
419
|
+
if (c.hour !== undefined && !c.meridiem && !c.midnight && c.hour >= 1 && c.hour <= 12) {
|
|
420
|
+
const used = clockHour(c);
|
|
421
|
+
const altHour = used === 12 ? 0 : used < 12 ? used + 12 : used - 12;
|
|
422
|
+
const alt = new Date(dateSet ? d : ref);
|
|
423
|
+
alt.setHours(altHour, c.minute ?? 0, 0, 0);
|
|
424
|
+
if (!dateSet && alt < ref)
|
|
425
|
+
alt.setDate(alt.getDate() + 1);
|
|
426
|
+
return { reason: 'meridiem', alternatives: [alt] };
|
|
427
|
+
}
|
|
428
|
+
// "다음 토요일": 규칙 23(오늘 제외 가장 가까운)과 "다음 주 토요일"이 다르면 후보로
|
|
429
|
+
if (c.weekdayDir === 'next' && c.weekday !== undefined) {
|
|
430
|
+
const alt = startOfWeek(ref, opts.weekStart);
|
|
431
|
+
alt.setDate(alt.getDate() + 7 + (opts.weekStart === 'monday' ? (c.weekday + 6) % 7 : c.weekday));
|
|
432
|
+
alt.setHours(d.getHours(), d.getMinutes(), 0, 0);
|
|
433
|
+
if (alt.getTime() !== d.getTime())
|
|
434
|
+
return { reason: 'next-weekday', alternatives: [alt] };
|
|
435
|
+
}
|
|
436
|
+
if (c.approximate)
|
|
437
|
+
return { reason: 'approximate-time', alternatives: [] };
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
408
440
|
function partDay(y, m0, part) {
|
|
409
441
|
if (part === 'start')
|
|
410
442
|
return 1;
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -34,10 +34,18 @@ export interface Components {
|
|
|
34
34
|
weekdayDir?: 'past' | 'next';
|
|
35
35
|
/** "1년 뒤", "내년 이맘때": 연/월/주 이동 시 기준일의 날짜를 유지 */
|
|
36
36
|
sameDay?: boolean;
|
|
37
|
+
/** "저녁", "아침"처럼 관례로 정한 시각 */
|
|
38
|
+
approximate?: boolean;
|
|
37
39
|
/** "내일 이 시간": 기준 시각의 시:분을 쓴다 */
|
|
38
40
|
sameTime?: boolean;
|
|
39
41
|
/** 음력 명절 (설날·추석·부처님 오신 날). 양력 날짜는 resolve가 util/lunarHolidays 표에서 찾는다 */
|
|
40
42
|
lunar?: 'seollal' | 'chuseok' | 'buddha';
|
|
43
|
+
/** "음력 3월 3일", "음력 윤5월 1일" */
|
|
44
|
+
lunarDate?: {
|
|
45
|
+
month: number;
|
|
46
|
+
day: number;
|
|
47
|
+
leap: boolean;
|
|
48
|
+
};
|
|
41
49
|
/** "매주 월요일", "매달 15일": 반복 규칙 (rrule은 resolve가 만든다) */
|
|
42
50
|
recur?: Omit<Recurrence, 'rrule'>;
|
|
43
51
|
/** 연 단위 이동 (내년=+1). 지정되면 "지난 월일 → 내년" 규칙을 적용하지 않는다 */
|
|
@@ -88,6 +96,16 @@ export interface Fragment {
|
|
|
88
96
|
index: number;
|
|
89
97
|
components: Components;
|
|
90
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* 해석이 갈릴 수 있다는 표시 (규칙 31). 기본 해석은 결과의 date(마감이면 end)이고, 다른 후보가 alternatives.
|
|
101
|
+
* - meridiem: 오전/오후 없는 시각 ("3시" → 15시, 후보 3시)
|
|
102
|
+
* - next-weekday: "다음 토요일"이 이번 주의 다음 토요일인지 다음 주 토요일인지
|
|
103
|
+
* - approximate-time: "저녁", "아침"처럼 관례로 정한 시각 (후보 없음)
|
|
104
|
+
*/
|
|
105
|
+
export interface Ambiguity {
|
|
106
|
+
reason: 'meridiem' | 'next-weekday' | 'approximate-time';
|
|
107
|
+
alternatives: Date[];
|
|
108
|
+
}
|
|
91
109
|
export interface ParseResult {
|
|
92
110
|
/** 해석된 시각 (구간이면 시작) */
|
|
93
111
|
date: Date;
|
|
@@ -101,5 +119,7 @@ export interface ParseResult {
|
|
|
101
119
|
hasTime: boolean;
|
|
102
120
|
/** 반복 일정이면 그 규칙. date는 기준 시각 이후 첫 발생 */
|
|
103
121
|
recurrence?: Recurrence;
|
|
122
|
+
/** 해석이 갈릴 수 있으면 그 이유와 다른 후보. 사용자에게 되묻거나 LLM에 넘길지 판단할 때 쓴다 */
|
|
123
|
+
ambiguity?: Ambiguity;
|
|
104
124
|
}
|
|
105
125
|
export type Parser = (text: string) => Fragment[];
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export type LunarHoliday = 'seollal' | 'chuseok' | 'buddha';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
export declare const LUNAR_YEARS: number[];
|
|
3
|
+
/**
|
|
4
|
+
* 음력 → 양력. 그 달에 없는 날(29일인 달의 30일)이나 표에 없는 날짜는 null.
|
|
5
|
+
* 마지막 달처럼 길이를 모르면 29일까지만 받는다.
|
|
6
|
+
*/
|
|
7
|
+
export declare function lunarToSolar(year: number, month: number, day: number, leap?: boolean): Date | null;
|
|
8
|
+
/** 설날(1/1)·부처님 오신 날(4/8)·추석(8/15)의 양력 날짜 */
|
|
9
|
+
export declare function lunarHoliday(year: number, which: LunarHoliday): Date | null;
|
|
10
|
+
/** 각 음력 달의 길이 (검증용). 모르면 null */
|
|
11
|
+
export declare function monthLengths(year: number): (number | null)[];
|
|
@@ -1,16 +1,82 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// 음력
|
|
2
|
+
// 음력 → 양력 변환표. 런타임 의존성 없이 음력을 다루려면 표가 필요하다.
|
|
3
3
|
//
|
|
4
4
|
// 출처: 한국천문연구원 천문우주지식정보 "월별 양음력 대조" (astro.kasi.re.kr/life/post/calendarData)
|
|
5
|
-
// 2025
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// 2025~2028년 페이지에서 음력 초하루(1일)가 되는 양력 날짜를 옮겼다.
|
|
6
|
+
// 설날·부처님 오신 날·추석 12개가 관공서 공휴일 목록과 일치하는지 tests/lunar.test.ts에서 확인한다.
|
|
7
|
+
// KASI는 2~3년 앞까지만 공식 자료를 낸다 (2026-10 기준 2028년까지).
|
|
8
|
+
// 새 연도가 공개되면 그 해의 초하루 목록을 추가하고, 직전 해 마지막 달의 길이가 확정되는지 확인할 것.
|
|
9
|
+
// 표에 없는 날짜는 추측하지 않고 null (규칙 10). 한국 음력은 한국 표준시 기준이라 중국과 하루 다를 수 있다.
|
|
8
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.
|
|
10
|
-
|
|
11
|
-
exports.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
exports.LUNAR_YEARS = void 0;
|
|
12
|
+
exports.lunarToSolar = lunarToSolar;
|
|
13
|
+
exports.lunarHoliday = lunarHoliday;
|
|
14
|
+
exports.monthLengths = monthLengths;
|
|
15
|
+
const MONTH_STARTS = {
|
|
16
|
+
2025: [
|
|
17
|
+
[1, false, 2025, 1, 29], [2, false, 2025, 2, 28], [3, false, 2025, 3, 29], [4, false, 2025, 4, 28],
|
|
18
|
+
[5, false, 2025, 5, 27], [6, false, 2025, 6, 25], [6, true, 2025, 7, 25], [7, false, 2025, 8, 23],
|
|
19
|
+
[8, false, 2025, 9, 22], [9, false, 2025, 10, 21], [10, false, 2025, 11, 20], [11, false, 2025, 12, 20],
|
|
20
|
+
[12, false, 2026, 1, 19],
|
|
21
|
+
],
|
|
22
|
+
2026: [
|
|
23
|
+
[1, false, 2026, 2, 17], [2, false, 2026, 3, 19], [3, false, 2026, 4, 17], [4, false, 2026, 5, 17],
|
|
24
|
+
[5, false, 2026, 6, 15], [6, false, 2026, 7, 14], [7, false, 2026, 8, 13], [8, false, 2026, 9, 11],
|
|
25
|
+
[9, false, 2026, 10, 11], [10, false, 2026, 11, 9], [11, false, 2026, 12, 9], [12, false, 2027, 1, 8],
|
|
26
|
+
],
|
|
27
|
+
2027: [
|
|
28
|
+
[1, false, 2027, 2, 7], [2, false, 2027, 3, 8], [3, false, 2027, 4, 7], [4, false, 2027, 5, 6],
|
|
29
|
+
[5, false, 2027, 6, 5], [6, false, 2027, 7, 4], [7, false, 2027, 8, 2], [8, false, 2027, 9, 1],
|
|
30
|
+
[9, false, 2027, 9, 30], [10, false, 2027, 10, 29], [11, false, 2027, 11, 28], [12, false, 2027, 12, 28],
|
|
31
|
+
],
|
|
32
|
+
2028: [
|
|
33
|
+
[1, false, 2028, 1, 27], [2, false, 2028, 2, 25], [3, false, 2028, 3, 26], [4, false, 2028, 4, 25],
|
|
34
|
+
[5, false, 2028, 5, 24], [5, true, 2028, 6, 23], [6, false, 2028, 7, 22], [7, false, 2028, 8, 20],
|
|
35
|
+
[8, false, 2028, 9, 19], [9, false, 2028, 10, 18], [10, false, 2028, 11, 16], [11, false, 2028, 12, 16],
|
|
36
|
+
[12, false, 2029, 1, 15],
|
|
37
|
+
],
|
|
16
38
|
};
|
|
39
|
+
exports.LUNAR_YEARS = Object.keys(MONTH_STARTS).map(Number);
|
|
40
|
+
const DAY = 86_400_000;
|
|
41
|
+
const solar = (s) => new Date(s[2], s[3] - 1, s[4]);
|
|
42
|
+
/** 그 음력 달의 다음 달 초하루 (모르면 null) */
|
|
43
|
+
function nextStart(year, index) {
|
|
44
|
+
const months = MONTH_STARTS[year];
|
|
45
|
+
if (index + 1 < months.length)
|
|
46
|
+
return solar(months[index + 1]);
|
|
47
|
+
const next = MONTH_STARTS[year + 1];
|
|
48
|
+
return next ? solar(next[0]) : null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 음력 → 양력. 그 달에 없는 날(29일인 달의 30일)이나 표에 없는 날짜는 null.
|
|
52
|
+
* 마지막 달처럼 길이를 모르면 29일까지만 받는다.
|
|
53
|
+
*/
|
|
54
|
+
function lunarToSolar(year, month, day, leap = false) {
|
|
55
|
+
const months = MONTH_STARTS[year];
|
|
56
|
+
if (!months || day < 1 || day > 30)
|
|
57
|
+
return null;
|
|
58
|
+
const index = months.findIndex(([m, l]) => m === month && l === leap);
|
|
59
|
+
if (index < 0)
|
|
60
|
+
return null;
|
|
61
|
+
const start = solar(months[index]);
|
|
62
|
+
const next = nextStart(year, index);
|
|
63
|
+
const length = next ? Math.round((next.getTime() - start.getTime()) / DAY) : 29;
|
|
64
|
+
if (day > length)
|
|
65
|
+
return null;
|
|
66
|
+
return new Date(start.getFullYear(), start.getMonth(), start.getDate() + day - 1);
|
|
67
|
+
}
|
|
68
|
+
/** 설날(1/1)·부처님 오신 날(4/8)·추석(8/15)의 양력 날짜 */
|
|
69
|
+
function lunarHoliday(year, which) {
|
|
70
|
+
if (which === 'seollal')
|
|
71
|
+
return lunarToSolar(year, 1, 1);
|
|
72
|
+
if (which === 'buddha')
|
|
73
|
+
return lunarToSolar(year, 4, 8);
|
|
74
|
+
return lunarToSolar(year, 8, 15);
|
|
75
|
+
}
|
|
76
|
+
/** 각 음력 달의 길이 (검증용). 모르면 null */
|
|
77
|
+
function monthLengths(year) {
|
|
78
|
+
return MONTH_STARTS[year].map((s, i) => {
|
|
79
|
+
const next = nextStart(year, i);
|
|
80
|
+
return next ? Math.round((next.getTime() - solar(s).getTime()) / DAY) : null;
|
|
81
|
+
});
|
|
82
|
+
}
|
package/dist/event.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ParseResult, Recurrence } from './types.js';
|
|
1
|
+
import type { Ambiguity, ParseResult, Recurrence } from './types.js';
|
|
2
2
|
/** parseEvent()의 결과: 할 일·일정 한 줄을 제목과 시점으로 나눈 것 */
|
|
3
3
|
export interface ParsedEvent {
|
|
4
4
|
/** 날짜 부분을 뺀 나머지 ("내일 3시 치과" → "치과"). 없으면 '' */
|
|
@@ -10,6 +10,8 @@ export interface ParsedEvent {
|
|
|
10
10
|
hasTime: boolean;
|
|
11
11
|
/** 반복 일정이면 그 규칙 */
|
|
12
12
|
recurrence?: Recurrence;
|
|
13
|
+
/** 해석이 갈릴 수 있으면 그 이유와 다른 후보 ("3시" → 15시, 후보 3시) */
|
|
14
|
+
ambiguity?: Ambiguity;
|
|
13
15
|
}
|
|
14
16
|
/**
|
|
15
17
|
* 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
|
package/dist/event.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ParseOptions, ParseResult } from './types.js';
|
|
2
2
|
import { type ParsedEvent } from './event.js';
|
|
3
|
-
export type { Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
|
|
3
|
+
export type { Ambiguity, Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
|
|
4
4
|
export type { ParsedEvent } from './event.js';
|
|
5
5
|
/**
|
|
6
6
|
* 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
|
package/dist/index.js
CHANGED
|
@@ -58,6 +58,9 @@ export function parseAll(text, options) {
|
|
|
58
58
|
const out = { ...r, date: fromWallClock(r.date, timezone) };
|
|
59
59
|
if (r.end)
|
|
60
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
|
+
}
|
|
61
64
|
return out;
|
|
62
65
|
});
|
|
63
66
|
}
|
package/dist/parsers/holiday.js
CHANGED
|
@@ -28,6 +28,8 @@ const MODIFIER = '(?:\\s*(전날|다음\\s*날|이튿날|당일))?';
|
|
|
28
28
|
const FIXED_RE = new RegExp(`(${Object.keys(FIXED).map((k) => k.replace(/ /g, '\\s*')).join('|')})${MODIFIER}`, 'g');
|
|
29
29
|
// "설날", "구정", "설 연휴", "추석 전날", "한가위", "부처님 오신 날". "설"만은 "설명", "설거지"와 겹쳐서 뒤에 연휴·전날 등이 올 때만
|
|
30
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;
|
|
31
33
|
const fixedKey = (name) => Object.keys(FIXED).find((k) => k.replace(/ /g, '') === name.replace(/\s/g, ''));
|
|
32
34
|
const shiftOf = (m) => (m ? SHIFT[m.replace(/\s/g, '')] : 0);
|
|
33
35
|
/** 공휴일·기념일·명절. 음력 명절의 양력 날짜는 resolve가 표에서 찾는다 */
|
|
@@ -41,6 +43,12 @@ export const holiday = (text) => {
|
|
|
41
43
|
c.dayOffset = shift;
|
|
42
44
|
out.push({ text: m[0], index: m.index, components: c });
|
|
43
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
|
+
}
|
|
44
52
|
for (const m of text.matchAll(LUNAR_RE)) {
|
|
45
53
|
const name = m[1].replace(/\s/g, '');
|
|
46
54
|
const lunar = name === '설날' || name === '구정' || name === '설' ? 'seollal'
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
const WORDS = {
|
|
2
2
|
그저께: -2,
|
|
3
|
+
// 업무용 한자어 (규칙 33)
|
|
4
|
+
금일: 0,
|
|
5
|
+
명일: 1,
|
|
6
|
+
익일: 1,
|
|
7
|
+
작일: -1,
|
|
3
8
|
내일모레: 2,
|
|
4
9
|
낼모레: 2,
|
|
5
10
|
낼: 1,
|
|
@@ -10,8 +15,8 @@ const WORDS = {
|
|
|
10
15
|
모레: 2,
|
|
11
16
|
글피: 3,
|
|
12
17
|
};
|
|
13
|
-
// "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
|
|
14
|
-
const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
|
|
18
|
+
// "금일", "익일 배송", "작일" (업무용 한자어). "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
|
|
19
|
+
const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])(?:금일|명일|익일|작일)|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
|
|
15
20
|
/** 오늘/내일/모레/글피/어제/그저께 */
|
|
16
21
|
export const relativeDay = (text) => {
|
|
17
22
|
const out = [];
|
|
@@ -4,6 +4,9 @@ const PART = { 초: 'start', 중순: 'middle', 말: 'end' };
|
|
|
4
4
|
const RE = /(지난|저번|이번|다다음|다음|담)\s*달\s*(초|중순|말|이맘때)?|(?<![가-힣])월(초|말)/g;
|
|
5
5
|
// "11월 말", "12월 초" (absoluteDate의 MONTH_ONLY와 겹치지만 part가 있는 경우 우선)
|
|
6
6
|
const ABS_PART = /(?<![\d가-힣])(\d{1,2})\s*월\s*(초|중순|말)/g;
|
|
7
|
+
// 업무용 한자어 달 (규칙 33): "익월 초", "금월 말까지", "전월 실적". "전월세"는 제외
|
|
8
|
+
const FORMAL_MONTH = /(?<![가-힣])(금|익|차|전)월(?!세)(?:\s*(초|중순|말))?/g;
|
|
9
|
+
const FORMAL_MONTH_OFFSET = { 금: 0, 익: 1, 차: 1, 전: -1 };
|
|
7
10
|
/** 이번달/다음달 (초/중순/말) */
|
|
8
11
|
export const relativeMonth = (text) => {
|
|
9
12
|
const out = [];
|
|
@@ -19,6 +22,12 @@ export const relativeMonth = (text) => {
|
|
|
19
22
|
c.monthPart = PART[m[2]];
|
|
20
23
|
out.push({ text: m[0], index: m.index, components: c });
|
|
21
24
|
}
|
|
25
|
+
for (const m of text.matchAll(FORMAL_MONTH)) {
|
|
26
|
+
const c = { monthOffset: FORMAL_MONTH_OFFSET[m[1]] };
|
|
27
|
+
if (m[2])
|
|
28
|
+
c.monthPart = PART[m[2]];
|
|
29
|
+
out.push({ text: m[0], index: m.index, components: c });
|
|
30
|
+
}
|
|
22
31
|
for (const m of text.matchAll(ABS_PART)) {
|
|
23
32
|
out.push({ text: m[0], index: m.index, components: { month: +m[1], monthPart: PART[m[2]] } });
|
|
24
33
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const WORDS = {
|
|
2
|
-
재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 내후년: 2,
|
|
2
|
+
재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 익년: 1, 내후년: 2,
|
|
3
3
|
};
|
|
4
4
|
// "올해 말", "내년 초", "작년", "재작년", "내후년", "지난해", "내년 이맘때", "연말", "연초"
|
|
5
|
-
const RE = /(
|
|
5
|
+
const RE = /(재작년|작년|지난해|올해|금년|내후년|내년|명년|익년)(?:\s*(초|말|이맘때))?|(?<![가-힣])(연말(?!정산)|연초)/g;
|
|
6
6
|
/** 올해/내년/작년 (초/말) */
|
|
7
7
|
export const relativeYear = (text) => {
|
|
8
8
|
const out = [];
|
package/dist/parsers/time.js
CHANGED
|
@@ -131,7 +131,7 @@ export const time = (text) => {
|
|
|
131
131
|
for (const m of text.matchAll(HALF_WORD)) {
|
|
132
132
|
if (!take(m.index, m[0].length))
|
|
133
133
|
continue;
|
|
134
|
-
out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true } });
|
|
134
|
+
out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true, approximate: true } });
|
|
135
135
|
}
|
|
136
136
|
for (const m of text.matchAll(WORD)) {
|
|
137
137
|
if (!take(m.index, m[0].length))
|
|
@@ -139,6 +139,9 @@ export const time = (text) => {
|
|
|
139
139
|
const c = { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true };
|
|
140
140
|
if (m[1] === '자정')
|
|
141
141
|
c.midnight = true;
|
|
142
|
+
// 정오·자정은 정확한 시각, 나머지(아침·저녁·밤·점심·낮·퇴근)는 관례
|
|
143
|
+
else if (m[1] !== '정오')
|
|
144
|
+
c.approximate = true;
|
|
142
145
|
out.push({ text: m[0], index: m.index, components: c });
|
|
143
146
|
}
|
|
144
147
|
return out;
|
package/dist/parsers/weekday.js
CHANGED
|
@@ -17,6 +17,13 @@ const NTH = new RegExp(`(?:(\\d{1,2})\\s*월\\s*)?(첫째|첫|둘째|셋째|넷
|
|
|
17
17
|
const NTH_NUM = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 다섯째: 5, 마지막: -1 };
|
|
18
18
|
// "2주 뒤 월요일", "3주 후 금요일" → N주 뒤의 그 주의 X요일 ("다다음주 월요일"과 같은 계산)
|
|
19
19
|
const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:뒤|후)\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
20
|
+
// 업무용 한자어 주 (규칙 33): "차주 금요일", "금주 월요일", "전주 수요일". 금주(禁酒)·전주(도시)·차주(車主)와
|
|
21
|
+
// 겹치므로 요일은 "요일/욜"까지 붙어야 하고, 요일 없이 쓸 때는 뒤에 날짜 말(내로·중으로·에·까지…)이 와야 한다.
|
|
22
|
+
// "전주"는 도시 이름과 너무 자주 겹쳐서 요일이 붙을 때만
|
|
23
|
+
const FORMAL_WEEK = { 전전: -2, 전: -1, 금: 0, 차: 1, 익: 1, 차차: 2 };
|
|
24
|
+
const FORMAL_WITH_DAY = new RegExp(`(?<![가-힣])(전전|차차|전|금|차|익)주\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
25
|
+
// "차주 중으로", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
|
|
26
|
+
const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)|에는|중으로|중에|내로|내|안에|안으로|까지|초|말|부터))|(금)주(?=\s*(?:중으로|중에|내로|내|안에|안으로|까지|초|말|부터)))/g;
|
|
20
27
|
// "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
|
|
21
28
|
const DIR = new RegExp(`(지난|저번|다음)\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
22
29
|
// "주말에 보자" ("이번 주말"은 위 WEEKEND가 먼저 잡는다)
|
|
@@ -32,6 +39,16 @@ export const weekday = (text) => {
|
|
|
32
39
|
const out = [];
|
|
33
40
|
const taken = [];
|
|
34
41
|
const overlaps = (i, len) => taken.some(([s, e]) => i < e && i + len > s);
|
|
42
|
+
for (const m of text.matchAll(FORMAL_WITH_DAY)) {
|
|
43
|
+
out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1]], weekday: WEEKDAY[m[2]] } });
|
|
44
|
+
taken.push([m.index, m.index + m[0].length]);
|
|
45
|
+
}
|
|
46
|
+
for (const m of text.matchAll(FORMAL_WEEK_ONLY)) {
|
|
47
|
+
if (overlaps(m.index, m[0].length))
|
|
48
|
+
continue;
|
|
49
|
+
out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1] ?? m[2]] } });
|
|
50
|
+
taken.push([m.index, m.index + m[0].length]);
|
|
51
|
+
}
|
|
35
52
|
for (const m of text.matchAll(WEEKS_LATER)) {
|
|
36
53
|
const n = parseKoreanNumber(m[1]);
|
|
37
54
|
if (n == null)
|
package/dist/resolve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { toRRule } from './parsers/recurrence.js';
|
|
2
|
-
import {
|
|
2
|
+
import { lunarHoliday, lunarToSolar } from './util/lunarHolidays.js';
|
|
3
3
|
function startOfWeek(d, weekStart) {
|
|
4
4
|
const r = new Date(d);
|
|
5
5
|
r.setHours(0, 0, 0, 0);
|
|
@@ -62,6 +62,7 @@ export function merge(list) {
|
|
|
62
62
|
}
|
|
63
63
|
const DATE_KEYS = [
|
|
64
64
|
'year', 'yearOffset', 'month', 'day', 'dayOffset', 'weekOffset', 'weekday', 'monthOffset', 'monthPart',
|
|
65
|
+
'lunar', 'lunarDate',
|
|
65
66
|
];
|
|
66
67
|
/** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
|
|
67
68
|
export function isTimeOnly(c) {
|
|
@@ -128,21 +129,24 @@ export function resolve(group, opts) {
|
|
|
128
129
|
d.setSeconds(0, 0);
|
|
129
130
|
let dateSet = false;
|
|
130
131
|
const hasTime = c.hour !== undefined || c.hourOffset !== undefined || c.minuteOffset !== undefined;
|
|
131
|
-
// 0. 음력
|
|
132
|
-
//
|
|
132
|
+
// 0. 음력 명절·음력 날짜 (규칙 29, 32): 연도를 말했으면 그 음력 해, 아니면 (연휴까지) 지나지 않은 가장 가까운 것.
|
|
133
|
+
// 음력 12월은 양력으로 다음 해 1월이라 작년 음력부터 본다. 표에 없는 날짜는 null
|
|
133
134
|
const hasYear = c.year !== undefined || c.yearOffset !== undefined;
|
|
134
|
-
|
|
135
|
+
const isLunar = c.lunar !== undefined || c.lunarDate !== undefined;
|
|
136
|
+
if (isLunar) {
|
|
135
137
|
const said = c.year ?? (c.yearOffset !== undefined ? ref.getFullYear() + c.yearOffset : undefined);
|
|
136
138
|
const tail = (c.dayOffset ?? 0) + (c.durationDays ? c.durationDays - 1 : 0);
|
|
137
|
-
const
|
|
139
|
+
const y0 = ref.getFullYear();
|
|
140
|
+
const years = said !== undefined ? [said] : [y0 - 1, y0, y0 + 1, y0 + 2];
|
|
141
|
+
const solarOf = (y) => c.lunar ? lunarHoliday(y, c.lunar) : lunarToSolar(y, c.lunarDate.month, c.lunarDate.day, c.lunarDate.leap);
|
|
138
142
|
let found = null;
|
|
139
143
|
for (const y of years) {
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
142
|
-
|
|
143
|
-
const lastMoment = new Date(
|
|
144
|
+
const s = solarOf(y);
|
|
145
|
+
if (!s)
|
|
146
|
+
continue; // 그 해에 없는 날 ("음력 2월 30일", 윤달)
|
|
147
|
+
const lastMoment = new Date(s.getFullYear(), s.getMonth(), s.getDate() + tail, 23, 59);
|
|
144
148
|
if (said !== undefined || lastMoment >= ref) {
|
|
145
|
-
found =
|
|
149
|
+
found = s;
|
|
146
150
|
break;
|
|
147
151
|
}
|
|
148
152
|
}
|
|
@@ -152,7 +156,7 @@ export function resolve(group, opts) {
|
|
|
152
156
|
dateSet = true;
|
|
153
157
|
}
|
|
154
158
|
// 1. 절대 날짜
|
|
155
|
-
if (!
|
|
159
|
+
if (!isLunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
|
|
156
160
|
let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
|
|
157
161
|
// "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
|
|
158
162
|
const m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
|
|
@@ -278,10 +282,13 @@ export function resolve(group, opts) {
|
|
|
278
282
|
// 안전장치: 어떤 경로로든 계산이 깨진 날짜는 내보내지 않는다
|
|
279
283
|
if (Number.isNaN(d.getTime()))
|
|
280
284
|
return null;
|
|
285
|
+
const ambiguity = ambiguityOf(c, d, dateSet, opts);
|
|
281
286
|
const first = group[0];
|
|
282
287
|
const last = group[group.length - 1];
|
|
283
288
|
// text는 parseAll()에서 원문 slice로 채운다. _end는 그때 쓰는 내부 값.
|
|
284
289
|
const result = { date: d, text: '', index: first.index, hasTime };
|
|
290
|
+
if (ambiguity)
|
|
291
|
+
result.ambiguity = ambiguity;
|
|
285
292
|
result._end = last.index + last.text.length;
|
|
286
293
|
// "오늘부터 3일간", "3시부터 2시간 동안": 시작일을 포함한 기간의 끝
|
|
287
294
|
if (c.durationDays !== undefined || c.durationMonths !== undefined || c.durationMinutes !== undefined) {
|
|
@@ -396,6 +403,31 @@ function resolveRecurrence(group, c, opts) {
|
|
|
396
403
|
result._end = last.index + last.text.length;
|
|
397
404
|
return result;
|
|
398
405
|
}
|
|
406
|
+
/** 규칙 31: 해석이 갈릴 수 있는 지점과 다른 후보 */
|
|
407
|
+
function ambiguityOf(c, d, dateSet, opts) {
|
|
408
|
+
const ref = opts.referenceDate;
|
|
409
|
+
// 오전/오후 없는 1~12시: 반대쪽 12시간이 후보 (날짜가 없으면 규칙 4대로 다음 발생)
|
|
410
|
+
if (c.hour !== undefined && !c.meridiem && !c.midnight && c.hour >= 1 && c.hour <= 12) {
|
|
411
|
+
const used = clockHour(c);
|
|
412
|
+
const altHour = used === 12 ? 0 : used < 12 ? used + 12 : used - 12;
|
|
413
|
+
const alt = new Date(dateSet ? d : ref);
|
|
414
|
+
alt.setHours(altHour, c.minute ?? 0, 0, 0);
|
|
415
|
+
if (!dateSet && alt < ref)
|
|
416
|
+
alt.setDate(alt.getDate() + 1);
|
|
417
|
+
return { reason: 'meridiem', alternatives: [alt] };
|
|
418
|
+
}
|
|
419
|
+
// "다음 토요일": 규칙 23(오늘 제외 가장 가까운)과 "다음 주 토요일"이 다르면 후보로
|
|
420
|
+
if (c.weekdayDir === 'next' && c.weekday !== undefined) {
|
|
421
|
+
const alt = startOfWeek(ref, opts.weekStart);
|
|
422
|
+
alt.setDate(alt.getDate() + 7 + (opts.weekStart === 'monday' ? (c.weekday + 6) % 7 : c.weekday));
|
|
423
|
+
alt.setHours(d.getHours(), d.getMinutes(), 0, 0);
|
|
424
|
+
if (alt.getTime() !== d.getTime())
|
|
425
|
+
return { reason: 'next-weekday', alternatives: [alt] };
|
|
426
|
+
}
|
|
427
|
+
if (c.approximate)
|
|
428
|
+
return { reason: 'approximate-time', alternatives: [] };
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
399
431
|
function partDay(y, m0, part) {
|
|
400
432
|
if (part === 'start')
|
|
401
433
|
return 1;
|
package/dist/types.d.ts
CHANGED
|
@@ -34,10 +34,18 @@ export interface Components {
|
|
|
34
34
|
weekdayDir?: 'past' | 'next';
|
|
35
35
|
/** "1년 뒤", "내년 이맘때": 연/월/주 이동 시 기준일의 날짜를 유지 */
|
|
36
36
|
sameDay?: boolean;
|
|
37
|
+
/** "저녁", "아침"처럼 관례로 정한 시각 */
|
|
38
|
+
approximate?: boolean;
|
|
37
39
|
/** "내일 이 시간": 기준 시각의 시:분을 쓴다 */
|
|
38
40
|
sameTime?: boolean;
|
|
39
41
|
/** 음력 명절 (설날·추석·부처님 오신 날). 양력 날짜는 resolve가 util/lunarHolidays 표에서 찾는다 */
|
|
40
42
|
lunar?: 'seollal' | 'chuseok' | 'buddha';
|
|
43
|
+
/** "음력 3월 3일", "음력 윤5월 1일" */
|
|
44
|
+
lunarDate?: {
|
|
45
|
+
month: number;
|
|
46
|
+
day: number;
|
|
47
|
+
leap: boolean;
|
|
48
|
+
};
|
|
41
49
|
/** "매주 월요일", "매달 15일": 반복 규칙 (rrule은 resolve가 만든다) */
|
|
42
50
|
recur?: Omit<Recurrence, 'rrule'>;
|
|
43
51
|
/** 연 단위 이동 (내년=+1). 지정되면 "지난 월일 → 내년" 규칙을 적용하지 않는다 */
|
|
@@ -88,6 +96,16 @@ export interface Fragment {
|
|
|
88
96
|
index: number;
|
|
89
97
|
components: Components;
|
|
90
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* 해석이 갈릴 수 있다는 표시 (규칙 31). 기본 해석은 결과의 date(마감이면 end)이고, 다른 후보가 alternatives.
|
|
101
|
+
* - meridiem: 오전/오후 없는 시각 ("3시" → 15시, 후보 3시)
|
|
102
|
+
* - next-weekday: "다음 토요일"이 이번 주의 다음 토요일인지 다음 주 토요일인지
|
|
103
|
+
* - approximate-time: "저녁", "아침"처럼 관례로 정한 시각 (후보 없음)
|
|
104
|
+
*/
|
|
105
|
+
export interface Ambiguity {
|
|
106
|
+
reason: 'meridiem' | 'next-weekday' | 'approximate-time';
|
|
107
|
+
alternatives: Date[];
|
|
108
|
+
}
|
|
91
109
|
export interface ParseResult {
|
|
92
110
|
/** 해석된 시각 (구간이면 시작) */
|
|
93
111
|
date: Date;
|
|
@@ -101,5 +119,7 @@ export interface ParseResult {
|
|
|
101
119
|
hasTime: boolean;
|
|
102
120
|
/** 반복 일정이면 그 규칙. date는 기준 시각 이후 첫 발생 */
|
|
103
121
|
recurrence?: Recurrence;
|
|
122
|
+
/** 해석이 갈릴 수 있으면 그 이유와 다른 후보. 사용자에게 되묻거나 LLM에 넘길지 판단할 때 쓴다 */
|
|
123
|
+
ambiguity?: Ambiguity;
|
|
104
124
|
}
|
|
105
125
|
export type Parser = (text: string) => Fragment[];
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export type LunarHoliday = 'seollal' | 'chuseok' | 'buddha';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
export declare const LUNAR_YEARS: number[];
|
|
3
|
+
/**
|
|
4
|
+
* 음력 → 양력. 그 달에 없는 날(29일인 달의 30일)이나 표에 없는 날짜는 null.
|
|
5
|
+
* 마지막 달처럼 길이를 모르면 29일까지만 받는다.
|
|
6
|
+
*/
|
|
7
|
+
export declare function lunarToSolar(year: number, month: number, day: number, leap?: boolean): Date | null;
|
|
8
|
+
/** 설날(1/1)·부처님 오신 날(4/8)·추석(8/15)의 양력 날짜 */
|
|
9
|
+
export declare function lunarHoliday(year: number, which: LunarHoliday): Date | null;
|
|
10
|
+
/** 각 음력 달의 길이 (검증용). 모르면 null */
|
|
11
|
+
export declare function monthLengths(year: number): (number | null)[];
|
|
@@ -1,13 +1,76 @@
|
|
|
1
|
-
// 음력
|
|
1
|
+
// 음력 → 양력 변환표. 런타임 의존성 없이 음력을 다루려면 표가 필요하다.
|
|
2
2
|
//
|
|
3
3
|
// 출처: 한국천문연구원 천문우주지식정보 "월별 양음력 대조" (astro.kasi.re.kr/life/post/calendarData)
|
|
4
|
-
// 2025
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
// 2025~2028년 페이지에서 음력 초하루(1일)가 되는 양력 날짜를 옮겼다.
|
|
5
|
+
// 설날·부처님 오신 날·추석 12개가 관공서 공휴일 목록과 일치하는지 tests/lunar.test.ts에서 확인한다.
|
|
6
|
+
// KASI는 2~3년 앞까지만 공식 자료를 낸다 (2026-10 기준 2028년까지).
|
|
7
|
+
// 새 연도가 공개되면 그 해의 초하루 목록을 추가하고, 직전 해 마지막 달의 길이가 확정되는지 확인할 것.
|
|
8
|
+
// 표에 없는 날짜는 추측하지 않고 null (규칙 10). 한국 음력은 한국 표준시 기준이라 중국과 하루 다를 수 있다.
|
|
9
|
+
const MONTH_STARTS = {
|
|
10
|
+
2025: [
|
|
11
|
+
[1, false, 2025, 1, 29], [2, false, 2025, 2, 28], [3, false, 2025, 3, 29], [4, false, 2025, 4, 28],
|
|
12
|
+
[5, false, 2025, 5, 27], [6, false, 2025, 6, 25], [6, true, 2025, 7, 25], [7, false, 2025, 8, 23],
|
|
13
|
+
[8, false, 2025, 9, 22], [9, false, 2025, 10, 21], [10, false, 2025, 11, 20], [11, false, 2025, 12, 20],
|
|
14
|
+
[12, false, 2026, 1, 19],
|
|
15
|
+
],
|
|
16
|
+
2026: [
|
|
17
|
+
[1, false, 2026, 2, 17], [2, false, 2026, 3, 19], [3, false, 2026, 4, 17], [4, false, 2026, 5, 17],
|
|
18
|
+
[5, false, 2026, 6, 15], [6, false, 2026, 7, 14], [7, false, 2026, 8, 13], [8, false, 2026, 9, 11],
|
|
19
|
+
[9, false, 2026, 10, 11], [10, false, 2026, 11, 9], [11, false, 2026, 12, 9], [12, false, 2027, 1, 8],
|
|
20
|
+
],
|
|
21
|
+
2027: [
|
|
22
|
+
[1, false, 2027, 2, 7], [2, false, 2027, 3, 8], [3, false, 2027, 4, 7], [4, false, 2027, 5, 6],
|
|
23
|
+
[5, false, 2027, 6, 5], [6, false, 2027, 7, 4], [7, false, 2027, 8, 2], [8, false, 2027, 9, 1],
|
|
24
|
+
[9, false, 2027, 9, 30], [10, false, 2027, 10, 29], [11, false, 2027, 11, 28], [12, false, 2027, 12, 28],
|
|
25
|
+
],
|
|
26
|
+
2028: [
|
|
27
|
+
[1, false, 2028, 1, 27], [2, false, 2028, 2, 25], [3, false, 2028, 3, 26], [4, false, 2028, 4, 25],
|
|
28
|
+
[5, false, 2028, 5, 24], [5, true, 2028, 6, 23], [6, false, 2028, 7, 22], [7, false, 2028, 8, 20],
|
|
29
|
+
[8, false, 2028, 9, 19], [9, false, 2028, 10, 18], [10, false, 2028, 11, 16], [11, false, 2028, 12, 16],
|
|
30
|
+
[12, false, 2029, 1, 15],
|
|
31
|
+
],
|
|
13
32
|
};
|
|
33
|
+
export const LUNAR_YEARS = Object.keys(MONTH_STARTS).map(Number);
|
|
34
|
+
const DAY = 86_400_000;
|
|
35
|
+
const solar = (s) => new Date(s[2], s[3] - 1, s[4]);
|
|
36
|
+
/** 그 음력 달의 다음 달 초하루 (모르면 null) */
|
|
37
|
+
function nextStart(year, index) {
|
|
38
|
+
const months = MONTH_STARTS[year];
|
|
39
|
+
if (index + 1 < months.length)
|
|
40
|
+
return solar(months[index + 1]);
|
|
41
|
+
const next = MONTH_STARTS[year + 1];
|
|
42
|
+
return next ? solar(next[0]) : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 음력 → 양력. 그 달에 없는 날(29일인 달의 30일)이나 표에 없는 날짜는 null.
|
|
46
|
+
* 마지막 달처럼 길이를 모르면 29일까지만 받는다.
|
|
47
|
+
*/
|
|
48
|
+
export function lunarToSolar(year, month, day, leap = false) {
|
|
49
|
+
const months = MONTH_STARTS[year];
|
|
50
|
+
if (!months || day < 1 || day > 30)
|
|
51
|
+
return null;
|
|
52
|
+
const index = months.findIndex(([m, l]) => m === month && l === leap);
|
|
53
|
+
if (index < 0)
|
|
54
|
+
return null;
|
|
55
|
+
const start = solar(months[index]);
|
|
56
|
+
const next = nextStart(year, index);
|
|
57
|
+
const length = next ? Math.round((next.getTime() - start.getTime()) / DAY) : 29;
|
|
58
|
+
if (day > length)
|
|
59
|
+
return null;
|
|
60
|
+
return new Date(start.getFullYear(), start.getMonth(), start.getDate() + day - 1);
|
|
61
|
+
}
|
|
62
|
+
/** 설날(1/1)·부처님 오신 날(4/8)·추석(8/15)의 양력 날짜 */
|
|
63
|
+
export function lunarHoliday(year, which) {
|
|
64
|
+
if (which === 'seollal')
|
|
65
|
+
return lunarToSolar(year, 1, 1);
|
|
66
|
+
if (which === 'buddha')
|
|
67
|
+
return lunarToSolar(year, 4, 8);
|
|
68
|
+
return lunarToSolar(year, 8, 15);
|
|
69
|
+
}
|
|
70
|
+
/** 각 음력 달의 길이 (검증용). 모르면 null */
|
|
71
|
+
export function monthLengths(year) {
|
|
72
|
+
return MONTH_STARTS[year].map((s, i) => {
|
|
73
|
+
const next = nextStart(year, i);
|
|
74
|
+
return next ? Math.round((next.getTime() - solar(s).getTime()) / DAY) : null;
|
|
75
|
+
});
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ko-chrono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "한국어 자연어 날짜/시간 파서 — '다음주 화요일 오후 3시' → Date",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": ["dist", "README.md", "LICENSE"],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs",
|
|
25
|
+
"prepublishOnly": "npm run build && npm test",
|
|
25
26
|
"test": "vitest run",
|
|
26
27
|
"test:watch": "vitest",
|
|
27
28
|
"typecheck": "tsc --noEmit",
|