ko-chrono 0.3.0 → 0.4.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.en.md ADDED
@@ -0,0 +1,146 @@
1
+ <div align="center">
2
+
3
+ # ko-chrono
4
+
5
+ **Find dates and times in Korean sentences and turn them into `Date`**
6
+
7
+ [![npm](https://img.shields.io/npm/v/ko-chrono?color=0969da)](https://www.npmjs.com/package/ko-chrono)
8
+ [![CI](https://github.com/jwn7/ko-chrono/actions/workflows/ci.yml/badge.svg)](https://github.com/jwn7/ko-chrono/actions/workflows/ci.yml)
9
+ [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](./package.json)
10
+ [![license](https://img.shields.io/npm/l/ko-chrono)](./LICENSE)
11
+
12
+ <a href="https://jwn7.github.io/ko-chrono/"><img alt="Playground demo: the date part of a Korean sentence is highlighted, and recurrences, ambiguous times, deadlines and holidays are shown as cards" src="./docs/demo.gif" width="720"></a>
13
+
14
+ **[▶ Try it in your browser](https://jwn7.github.io/ko-chrono/)** · [한국어](./README.md)
15
+
16
+ </div>
17
+
18
+ <br>
19
+
20
+ `ko-chrono` is a natural-language date parser for **Korean**, in the spirit of [chrono-node](https://github.com/wanasit/chrono) (which supports English, Japanese, Chinese and others, but not Korean).
21
+ It finds expressions like "다음주 화요일 오후 3시" (next Tuesday 3 PM) inside a sentence and resolves them against a reference date.
22
+
23
+ ```bash
24
+ npm install ko-chrono
25
+ ```
26
+
27
+ Zero runtime dependencies. ESM and CommonJS, browser and Node.
28
+
29
+ ## Examples
30
+
31
+ > All results below use the reference time **2026-10-30 (Fri) 14:00**.
32
+
33
+ ### Find a meeting time in a chat message
34
+
35
+ Surrounding words are skipped. `text` and `index` tell you where the date was, so you can highlight it.
36
+
37
+ ```ts
38
+ import { parse } from 'ko-chrono';
39
+
40
+ const r = parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?'); // "Then shall we meet in Gangnam next Wed at 7 PM?"
41
+
42
+ r.date; // 2026-11-04 19:00
43
+ r.text; // '다음주 수요일 저녁 7시'
44
+ r.index; // 3
45
+ ```
46
+
47
+ ### Quick-add for a to-do app
48
+
49
+ `parseEvent` splits a line into a **title** and a **time**. Deadlines ("\~까지", "by …") go into `end`.
50
+
51
+ ```ts
52
+ import { parseEvent } from 'ko-chrono';
53
+
54
+ parseEvent('다음주 금요일까지 보고서 제출'); // "Submit the report by next Friday"
55
+ // { title: '보고서 제출', end: 2026-11-06 23:59, hasTime: false, ... }
56
+
57
+ parseEvent('내일 오후 3시에 강남역에서 친구 만나기'); // "Meet a friend at Gangnam station tomorrow 3 PM"
58
+ // { title: '강남역에서 친구 만나기', date: 2026-10-31 15:00, hasTime: true }
59
+ ```
60
+
61
+ ### Recurring events → calendar
62
+
63
+ Recurrences come out as an [RFC 5545 RRULE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html), and `toICS` builds a calendar file you can import into Google, Apple or Outlook Calendar.
64
+
65
+ ```ts
66
+ import { parse, parseEvent, toICS } from 'ko-chrono';
67
+
68
+ parse('매주 월수금 오전 7시 운동')?.recurrence?.rrule; // every Mon/Wed/Fri 7 AM → 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
69
+ parse('매달 마지막 주 금요일 회식')?.recurrence?.rrule; // last Friday of every month → 'FREQ=MONTHLY;BYDAY=-1FR'
70
+ parse('다음달까지 매주 월요일 9시')?.recurrence?.rrule; // every Monday 9 AM until next month
71
+ // → 'FREQ=WEEKLY;BYDAY=MO;UNTIL=20261130T235900'
72
+
73
+ toICS(parseEvent('다음달까지 매주 월요일 9시 팀 회의'), { timezone: 'Asia/Seoul' });
74
+ // BEGIN:VCALENDAR … SUMMARY:팀 회의 … DTSTART;TZID=Asia/Seoul:20261102T090000 …
75
+ ```
76
+
77
+ ### Tell me when it's ambiguous
78
+
79
+ When a sentence can be read more than one way (e.g. "3시" with no AM/PM), you get the default reading plus the alternatives, so you can ask the user or hand only those sentences to an LLM.
80
+
81
+ ```ts
82
+ const r = parse('3시에 보자'); // "See you at 3"
83
+
84
+ r.date; // today 15:00 (1–6 o'clock without AM/PM is read as PM)
85
+ r.ambiguity; // { reason: 'meridiem', alternatives: [tomorrow 03:00] }
86
+ ```
87
+
88
+ ### On a server
89
+
90
+ Servers usually run in UTC, so "오후 3시" (3 PM) would be 3 PM UTC. Pass `timezone`.
91
+
92
+ ```ts
93
+ parse('내일 오후 3시', { timezone: 'Asia/Seoul' }); // 3 PM in Seoul, whatever the server's timezone
94
+ ```
95
+
96
+ ## What it understands
97
+
98
+ | Kind | Examples |
99
+ |---|---|
100
+ | Relative days | 오늘 (today) · 내일 (tomorrow) · 모레 · 글피 · 어제 · 그저께 · 낼 |
101
+ | N later / ago | 3일 뒤 · 2주 전 · 한 시간 반 뒤 · 30분 내로 |
102
+ | Weeks & weekdays | 다음주 화요일 · 담주 월욜 (slang) · 지난 금요일 · 이번 주말 · 11월 첫째 주 월요일 |
103
+ | Dates | 11월 5일 · 2026-11-05 · 11/5 · 26.11.5 · 11/3(화) |
104
+ | Months & years | 이번달 말 · 다음달 중순 · 내년 초 · 연말 · 내년 이맘때 |
105
+ | Times | 오후 3시 · 3시 반 · 15:30 · 3pm · 정오 · 자정 · 점심 · 퇴근 |
106
+ | Deadlines & ranges | \~까지 · \~안에 · 이번 주 안에 · A부터 B까지 · 3\~5시 · 오늘부터 3일간 |
107
+ | Holidays | 크리스마스 · 설 연휴 · 추석 전날 · 부처님 오신 날 · 음력 3월 3일 (lunar) |
108
+ | Business words | 금일 · 익일 · 차주 · 익월 초 · 금월 말까지 |
109
+ | Recurrence | 매일 · 매주 월수금 · 격주 · 매달 15일 · 매달 마지막 주 금요일 · 다음달까지 · 총 8회 |
110
+
111
+ It is deliberately conservative: when in doubt it returns `null` rather than a wrong date ("3일 연속" = "3 days in a row", "주 5일 근무" = "5 days a week", "내가 낼게" = "I'll pay" are not dates).
112
+ Every rule is listed in the [Korean README](./README.md#이런-문장을-읽습니다) and [SPEC.md](./SPEC.md).
113
+
114
+ ## API
115
+
116
+ | Function | Returns |
117
+ |---|---|
118
+ | `parse(text, options?)` | The first `ParseResult`, or `null` |
119
+ | `parseAll(text, options?)` | Every `ParseResult` in the sentence |
120
+ | `parseDate(text, options?)` | Shorthand for `parse(...)?.date` |
121
+ | `parseEvent(text, options?)` | `{ title, date, end?, hasTime, recurrence? }` |
122
+ | `toICS(result, options?)` | An `.ics` string (`title`, `timezone`, `durationMinutes`) |
123
+
124
+ | `ParseResult` | |
125
+ |---|---|
126
+ | `date` | The resolved time. Start of a range; the reference time for a deadline |
127
+ | `end` | End of a range or the deadline |
128
+ | `text` / `index` | The matched part of the sentence and its position |
129
+ | `hasTime` | Whether a time of day was given. If `false`, `date` is at `defaultHour` |
130
+ | `recurrence` | `{ freq, interval, byWeekday, …, until?, count?, rrule }` |
131
+ | `ambiguity` | `{ reason: 'meridiem' \| 'next-weekday' \| 'approximate-time', alternatives }` |
132
+
133
+ | Option | Default | |
134
+ |---|---|---|
135
+ | `referenceDate` | `new Date()` | What "tomorrow" and "next week" are relative to |
136
+ | `timezone` | runtime | IANA name such as `'Asia/Seoul'` |
137
+ | `weekStart` | `'monday'` | First day of the week (`'sunday'` also works) |
138
+ | `defaultHour` | `0` | Hour used for dates without a time |
139
+
140
+ ## Contributing
141
+
142
+ The most useful contribution is **real sentences**. If a sentence isn't recognised or is read wrong, please [open an issue](https://github.com/jwn7/ko-chrono/issues/new?template=sentence.yml) (Korean or English is fine).
143
+
144
+ ## License
145
+
146
+ [MIT](./LICENSE)
package/README.md CHANGED
@@ -9,160 +9,112 @@
9
9
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](./package.json)
10
10
  [![license](https://img.shields.io/npm/l/ko-chrono)](./LICENSE)
11
11
 
12
- <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="./docs/banner-dark.svg">
14
- <img alt="'그럼 다음주 화요일 오후 3시에 볼까?'에서 '다음주 화요일 오후 3시'를 찾아 2026-11-03 15:00으로 바꾼다" src="./docs/banner-light.svg" width="760">
15
- </picture>
12
+ <a href="https://jwn7.github.io/ko-chrono/"><img alt="playground 데모: 문장을 입력하면 날짜 부분이 형광펜으로 표시되고, 반복 일정·모호한 시각·마감·명절 연휴가 카드로 나온다" src="./docs/demo.gif" width="720"></a>
16
13
 
17
- [설치](#설치) · [사용법](#사용법) · [지원 표현](#지원-표현) · [해석 규칙](#해석-규칙) · [예제](#예제) · [문장 제보](https://github.com/jwn7/ko-chrono/issues/new?template=sentence.yml)
14
+ **[▶ 브라우저에서 바로 해 보기](https://jwn7.github.io/ko-chrono/)** · [English](./README.en.md)
18
15
 
19
16
  </div>
20
17
 
21
18
  <br>
22
19
 
23
- [chrono-node](https://github.com/wanasit/chrono)는 영어·일본어·중국어 등은 지원하지만 한국어는 없습니다.
24
- [es-hangul](https://github.com/toss/es-hangul)이 **글자**를 다룬다면, ko-chrono는 **문장 속 시간**을 다룹니다.
25
-
26
- | 입력 | 결과 <sub>(기준: 2026-10-30 금 14:00)</sub> |
27
- |---|---|
28
- | 그럼 **다음주 수요일 저녁 7시**에 강남에서 볼까? | `2026-11-04 19:00` |
29
- | **이번 주 안에** 보내주세요 | 마감 `2026-11-01 23:59` |
30
- | **오늘부터 3일간** 휴가 | `10-30` \~ `11-01 23:59` |
31
- | **담주 월욜 점심** ㄱ? | `2026-11-02 12:00` |
32
- | **11월 첫째 주 월요일**에 시작 | `2026-11-02` |
33
- | **3시**에 보자 | `2026-10-30 15:00` |
34
- | 내가 낼게 | `null` (날짜 아님) |
35
-
36
- <br>
37
-
38
- ## 특징
39
-
40
- - **문장 중간에서 찾기** — 앞뒤 말은 무시하고 날짜 부분만, 여러 개면 전부
41
- - **구간과 마감** — "\~까지", "A부터 B까지", "3\~5시", "오늘부터 3일간", "이번 주 내내"
42
- - **반복 일정** — "매주 월수금 7시", "격주 화요일", "매달 마지막 주 금요일" → 캘린더용 RRULE
43
- - **공휴일·명절** — "크리스마스 이브", "설 연휴", "추석 전날" (음력은 한국천문연구원 자료 기준)
44
- - **구어체** — "담주 월욜", "낼 오후 2시", "내일모레", "3시쯤", 띄어쓰기 생략
45
- - **틀리느니 비운다** — "3일 연속", "2시간짜리", "1월생"은 날짜로 잡지 않음
46
- - **서버에서도 정확** — `timezone: 'Asia/Seoul'` 한 줄이면 UTC 서버에서도 한국 시간
47
- - **가볍다** — 런타임 의존성 0, ESM·CommonJS 둘 다, 브라우저·Node 모두
48
-
49
- <br>
50
-
51
- ## 설치
20
+ `ko-chrono`는 "다음주 화요일 오후 3시" 같은 한국어 표현을 찾아 `Date`로 바꿔 주는 JavaScript 라이브러리입니다.
21
+ [chrono-node](https://github.com/wanasit/chrono)에 없는 한국어를 채우려고 만들었습니다. [es-hangul](https://github.com/toss/es-hangul)이 **글자**를 다룬다면, ko-chrono는 **문장 속 시간**을 다룹니다.
52
22
 
53
23
  ```bash
54
24
  npm install ko-chrono
55
25
  ```
56
26
 
57
- ## 사용법
27
+ 런타임 의존성은 없고, ESM·CommonJS·브라우저·Node 어디서나 씁니다.
58
28
 
59
- ```ts
60
- import { parse, parseAll, parseDate } from 'ko-chrono';
29
+ ## 사용 예시
30
+
31
+ > 아래 결과는 모두 기준 시각 **2026-10-30 (금) 14:00** 기준입니다.
61
32
 
62
- parseDate('다음주 화요일 오후 3시'); // Date: 2026-11-03 15:00
33
+ ### 채팅에서 약속 시각 찾기
63
34
 
64
- parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?');
65
- // { date: 2026-11-04 19:00, text: '다음주 수요일 저녁 7시', index: 3, hasTime: true }
35
+ 앞뒤 말은 건너뛰고 날짜 부분만 찾습니다. `text`와 `index`로 메시지 안에서 강조 표시를 할 수 있습니다.
66
36
 
67
- parse('이번달 말까지 제출');
68
- // { date: 지금, end: 2026-10-31 23:59, text: '이번달 말까지', hasTime: false }
37
+ ```ts
38
+ import { parse } from 'ko-chrono';
69
39
 
70
- parseAll('내일 10시 아니면 모레 2시');
71
- // [ { date: 10-31 10:00, ... }, { date: 11-01 14:00, ... } ]
40
+ const r = parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?');
41
+
42
+ r.date; // 2026-11-04 19:00
43
+ r.text; // '다음주 수요일 저녁 7시'
44
+ r.index; // 3
72
45
  ```
73
46
 
74
- ### 할 일·일정 한 줄: `parseEvent`
47
+ ### 할 일 앱 빠른 입력
75
48
 
76
- 할 일 앱이나 캘린더의 빠른 입력처럼, 한 줄을 **제목과 시점**으로 나눕니다.
49
+ `parseEvent`는 한 줄을 **제목**과 **시점**으로 나눕니다. 마감 표현은 `end`에 들어갑니다.
77
50
 
78
51
  ```ts
79
52
  import { parseEvent } from 'ko-chrono';
80
53
 
81
54
  parseEvent('다음주 금요일까지 보고서 제출');
82
- // { title: '보고서 제출', date: 지금, end: 2026-11-06 23:59, hasTime: false }
55
+ // { title: '보고서 제출', end: 2026-11-06 23:59, hasTime: false, ... }
83
56
 
84
57
  parseEvent('내일 오후 3시에 강남역에서 친구 만나기');
85
58
  // { title: '강남역에서 친구 만나기', date: 2026-10-31 15:00, hasTime: true }
86
-
87
- parseEvent('우유 사기');
88
- // { title: '우유 사기', date: null, hasTime: false }
89
59
  ```
90
60
 
91
- ### 애매한 해석: `ambiguity`
61
+ ### 반복 일정을 캘린더로
92
62
 
93
- "3시"가 오후인지 새벽인지처럼 해석이 갈릴 수 있으면, 기본 해석은 그대로 두고 다른 후보를 함께 알려 줍니다.
94
- 사용자에게 되묻거나, 이런 문장만 LLM에 넘길 때 쓰면 됩니다.
63
+ 반복 표현은 [RRULE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html)로 나와서 Google·Apple 캘린더에 그대로 넣을 수 있습니다.
95
64
 
96
65
  ```ts
97
- const r = parse('3시에 보자');
98
- // r.date → 오늘 15:00 (오전/오후가 없는 1~6시는 오후로)
99
- // r.ambiguity → { reason: 'meridiem', alternatives: [내일 03:00] }
100
-
101
- if (!r) askLLM(text); // 날짜를 못 찾음
102
- else if (r.ambiguity?.alternatives.length) confirm(r); // "오후 3시 맞으세요?"
66
+ parse('매주 월수금 오전 7시 운동')?.recurrence?.rrule; // 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
67
+ parse('매달 마지막 주 금요일 회식')?.recurrence?.rrule; // 'FREQ=MONTHLY;BYDAY=-1FR'
68
+ parse('다음달까지 매주 월요일 9시')?.recurrence?.rrule; // 'FREQ=WEEKLY;BYDAY=MO;UNTIL=20261130T235900'
69
+ parse('매주 수요일 2시, 총 8회')?.recurrence?.rrule; // 'FREQ=WEEKLY;BYDAY=WE;COUNT=8'
103
70
  ```
104
71
 
105
- | `reason` | 언제 | `alternatives` |
106
- |---|---|---|
107
- | `meridiem` | 오전/오후 없는 1\~12시 ("3시", "내일 7시") | 반대쪽 12시간 |
108
- | `next-weekday` | "다음 토요일"이 이번 주 토요일일 수도, 다음 주 토요일일 수도 | 다른 쪽 날짜 |
109
- | `approximate-time` | "저녁"(19시), "아침"(8시)처럼 관례로 정한 시각 | 없음 |
72
+ `toICS`로 바로 캘린더 파일(.ics)을 만들 수도 있습니다.
73
+
74
+ ```ts
75
+ import { parseEvent, toICS } from 'ko-chrono';
76
+
77
+ toICS(parseEvent('다음달까지 매주 월요일 9시 팀 회의'), { timezone: 'Asia/Seoul' });
78
+ // BEGIN:VCALENDAR … SUMMARY:팀 회의 … DTSTART;TZID=Asia/Seoul:20261102T090000
79
+ // RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261130T145900Z …
80
+ ```
110
81
 
111
- ### 반복 일정
82
+ ### 애매하면 알려 주기
112
83
 
113
- `recurrence.rrule`은 [RFC 5545 RRULE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html)이라 Google·Apple 캘린더, iCal에 그대로 넣을 수 있습니다. `date`는 기준 시각 이후 첫 발생입니다.
84
+ "3시"처럼 해석이 갈리는 문장은 기본 해석과 함께 다른 후보를 줍니다. 사용자에게 되묻거나, 이런 문장만 LLM에 넘길 때 씁니다.
114
85
 
115
86
  ```ts
116
- parse('매주 월수금 오전 7시 운동');
117
- // {
118
- // date: 2026-11-02 07:00,
119
- // text: '매주 월수금 오전 7시',
120
- // recurrence: { freq: 'WEEKLY', interval: 1, byWeekday: [1, 3, 5], rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR' }
121
- // }
122
-
123
- parse('매달 마지막 주 금요일 회식')?.recurrence?.rrule; // 'FREQ=MONTHLY;BYDAY=-1FR'
124
- parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;INTERVAL=2;BYDAY=TU'
87
+ const r = parse('3시에 보자');
88
+
89
+ r.date; // 오늘 15:00
90
+ r.ambiguity; // { reason: 'meridiem', alternatives: [내일 03:00] }
125
91
  ```
126
92
 
127
- ### API
93
+ ### 서버에서 쓸 때
128
94
 
129
- | 함수 | 반환 |
130
- |---|---|
131
- | `parse(text, options?)` | 첫 번째 표현의 `ParseResult`, 없으면 `null` |
132
- | `parseAll(text, options?)` | 모든 표현의 `ParseResult[]` |
133
- | `parseDate(text, options?)` | `parse(...)?.date` 단축형 |
134
- | `parseEvent(text, options?)` | `{ title, date, end?, hasTime, recurrence? }` — 날짜를 뺀 나머지가 제목 |
95
+ 서버는 보통 UTC로 돌아서 "오후 3시"가 한국 시간과 어긋납니다. `timezone`을 넘기세요.
135
96
 
136
- | `ParseResult` | |
137
- |---|---|
138
- | `date` | 찾은 시점. 구간이면 시작, 마감이면 기준 시각 |
139
- | `end` | 구간의 끝 또는 마감. 없으면 `undefined` |
140
- | `text` | 문장에서 찾은 부분 (`'다음주 수요일 저녁 7시'`) |
141
- | `index` | 문장 안에서의 위치 |
142
- | `hasTime` | 시각까지 말했는지. `false`면 `date`는 그날 `defaultHour`시 |
143
- | `recurrence` | 반복 일정이면 규칙 (`freq`, `interval`, `byWeekday`, …, `rrule`) |
144
- | `ambiguity` | 해석이 갈릴 수 있으면 `{ reason, alternatives }` |
97
+ ```ts
98
+ parse('내일 오후 3시', { timezone: 'Asia/Seoul' }); // 서버 시간대와 상관없이 서울 오후 3시
99
+ ```
145
100
 
146
- ### 옵션
101
+ ## 이런 문장을 읽습니다
147
102
 
148
- | 옵션 | 기본값 | 설명 |
149
- |---|---|---|
150
- | `referenceDate` | `new Date()` | "내일", "다음주"의 기준 시각 |
151
- | `timezone` | 실행 환경 | "오후 3시"를 어느 시간대의 3시로 볼지 (IANA 이름) |
152
- | `weekStart` | `'monday'` | "이번주/다음주" 계산 기준 (`'sunday'` 가능) |
153
- | `defaultHour` | `0` | 시각 없는 날짜에 넣을 시 |
103
+ | 입력 | 결과 |
104
+ |---|---|
105
+ | **이번 주 안에** 보내주세요 | 마감 `11-01 23:59` |
106
+ | **오늘부터 3일간** 휴가 | `10-30` \~ `11-01 23:59` |
107
+ | **담주 월욜 점심** ㄱ? | `11-02 12:00` |
108
+ | **11월 첫째 주 월요일**에 시작 | `11-02` |
109
+ | **차주 중으로** 회신 부탁드립니다 | 마감 `11-08 23:59` |
110
+ | **설 연휴**에 내려가 | `2027-02-06` \~ `02-08` |
111
+ | 내가 낼게 | `null` (날짜가 아님) |
154
112
 
155
- > [!TIP]
156
- > **서버에서는 `timezone`을 꼭 넘기세요.** Vercel, AWS Lambda 같은 서버는 보통 UTC로 돌아서, 그대로 쓰면 한국 사용자의 "오후 3시"가 한국 시간 자정이 됩니다.
157
- > ```ts
158
- > parse('내일 오후 3시', { timezone: 'Asia/Seoul' }); // 서버 시간대와 상관없이 서울 오후 3시
159
- > ```
160
- > 브라우저에서는 사용자의 시간대가 기본값이라 보통 필요 없습니다. 내장 `Intl`로 계산해서 의존성이 늘지 않습니다.
113
+ <details>
114
+ <summary><b>지원하는 표현 전체</b></summary>
161
115
 
162
116
  <br>
163
117
 
164
- ## 지원 표현
165
-
166
118
  | 분류 | 예 |
167
119
  |---|---|
168
120
  | 상대 날짜 | 오늘 · 내일 · 모레 · 글피 · 어제 · 그저께 · 내일모레 · 낼 |
@@ -176,28 +128,28 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
176
128
  | 구간 | A부터 B까지 · 3\~5시 · 월\~금 · 오늘부터 3일간 · 3박 4일 · 이번 주 내내 |
177
129
  | 공휴일·명절 | 크리스마스 · 빼빼로데이 · 광복절 전날 · 설날 · 설 연휴 · 추석 전날 · 부처님 오신 날 |
178
130
  | 음력 | 음력 3월 3일 · 음력 윤5월 1일 · 2028년 음력 9월 9일 |
131
+ | 업무용 한자어 | 금일 · 명일 · 익일 · 작일 · 금주 · 차주 · 전주 수요일 · 익월 초 · 금월 말까지 · 익년 |
179
132
  | 반복 | 매일 · 매주 월수금 · 매주 월\~금 · 격주 · 평일마다 · 매달 15일 · 매월 말일 · 매달 마지막 주 금요일 · 매년 3월 1일 · 2시간마다 |
133
+ | 반복의 시작·끝 | 다음달까지 매주 월요일 · 다음주부터 매일 · 10주 동안 매주 화요일 · 총 8회 · 10번 반복 · 매주 화목 오후 3\~5시 |
180
134
  | 문장 | 띄어쓰기 생략 · 조사(은·는·도) · 어림말(쯤·경·께) · 여러 개 추출 |
181
135
 
182
- 입력별 결과 전체는 [SPEC.md](./SPEC.md)에 있습니다. 인식 못 하는 문장이 있으면 [제보해 주세요](https://github.com/jwn7/ko-chrono/issues/new?template=sentence.yml).
183
-
184
- <br>
185
-
186
- ## 해석 규칙
136
+ 입력별 결과는 [SPEC.md](./SPEC.md)에 모두 있습니다.
187
137
 
188
- 애매한 표현은 아래처럼 정해 두었습니다. 같은 문장은 언제나 같은 답을 냅니다.
138
+ </details>
189
139
 
190
140
  <details>
191
- <summary><b>규칙 전체 보기</b> (24개 중 주요 규칙)</summary>
141
+ <summary><b>애매한 표현은 이렇게 정했습니다</b></summary>
192
142
 
193
143
  <br>
194
144
 
145
+ 같은 문장은 언제나 같은 답을 냅니다.
146
+
195
147
  **요일과 주**
196
148
  - "다음주 월요일" = 기준일이 속한 주의 다음 주 월요일 (금요일에 말하면 3일 뒤)
197
149
  - "월요일" 단독 = 오늘 포함 가장 가까운 미래의 월요일
198
150
  - "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
199
151
  - "이번 주말" = 그 주의 토요일. "주말" 단독 = 다가오는 토요일 (토·일이면 오늘)
200
- - "11월 첫째 주 월요일" = 11월의 첫 번째 월요일. 요일이 없으면 N번째 월요일
152
+ - "11월 첫째 주 월요일" = 11월의 첫 번째 월요일
201
153
 
202
154
  **시각**
203
155
  - 오전/오후가 없는 1\~6시는 오후 ("3시에 보자" → 15:00). 7\~11시는 그대로
@@ -207,9 +159,10 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
207
159
 
208
160
  **날짜**
209
161
  - 월일만 있고 이미 지났으면 내년. "올해 3월 1일"처럼 연도를 말하면 그대로
162
+ - "5일까지"처럼 일만 있으면 이번 달, 오늘이 그날을 넘었으면 다음 달. 그 달에 없는 날(11월의 "31일")은 `null`
210
163
  - "3월", "2월 말"도 그 달이 다 지났으면 내년
211
164
  - 두 자리 연도: 00\~69 → 20xx, 70\~99 → 19xx
212
- - "내년 이맘때", "1년 뒤"는 오늘의 월·일 유지 (2/29 + 1년 → 2/28)
165
+ - "한 달 뒤", "내년 이맘때", "1년 뒤"는 오늘의 날짜 유지 (1/31 + 한 달 → 2/28, 2/29 + 1년 → 2/28)
213
166
 
214
167
  **구간·마감**
215
168
  - "\~까지", "\~안에" → `date`는 기준 시각, `end`는 마감 (시각이 없으면 23:59)
@@ -217,65 +170,77 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
217
170
  - "오늘부터 3일간" → `end`는 3일째 되는 날 23:59
218
171
 
219
172
  **명절**
220
- - "음력 3월 3일"처럼 음력이라고 말한 날짜도 같은 자료로 변환합니다
221
- - 설날·추석·부처님 오신 날은 [한국천문연구원](https://astro.kasi.re.kr/life/post/calendarData) 자료가 있는 연도(현재 2025\~2028)만. 그 뒤는 추측하지 않고 `null`
173
+ - 설날·추석·부처님 오신 날과 음력 날짜는 [한국천문연구원](https://astro.kasi.re.kr/life/post/calendarData) 자료가 있는 연도(현재 2025\~2028)만. 그 밖은 추측하지 않고 `null`
222
174
  - "설 연휴", "추석 연휴" = 전날부터 다음날까지 3일
223
175
 
224
176
  **잡지 않는 것**
225
- - 없는 날짜 (2월 30일, 11월 31일), 날짜와 요일이 안 맞는 경우 ("11월 5일 수요일")
226
- - 기간만 있는 표현 ("3일간", "3박 4일"), 날짜가 아닌 말 ("3일 연속", "1월생", "내가 낼게")
177
+ - 없는 날짜 (2월 30일), 날짜와 요일이 안 맞는 경우 ("11월 5일 수요일")
178
+ - 기간만 있는 표현 ("3일간", "3박 4일"), 날짜가 아닌 말 ("3일 연속", "주 5일 근무", "30일 챌린지", "24시 편의점", "내가 낼게")
179
+ - 한자어가 다른 뜻일 때: "금주 결심"(禁酒), "전주 여행"(도시), "차주에게 연락"(車主)
227
180
  - "내일 30분 후"처럼 날짜와 상대 시간이 섞이면 두 개의 결과로 나눔
228
181
 
229
182
  </details>
230
183
 
231
- <br>
184
+ ## API
232
185
 
233
- ## 예제
186
+ | 함수 | 반환 |
187
+ |---|---|
188
+ | `parse(text, options?)` | 첫 번째 표현의 `ParseResult`, 없으면 `null` |
189
+ | `parseAll(text, options?)` | 모든 표현의 `ParseResult[]` |
190
+ | `parseDate(text, options?)` | `parse(...)?.date` 단축형 |
191
+ | `parseEvent(text, options?)` | `{ title, date, end?, hasTime, recurrence? }` |
192
+ | `toICS(result, options?)` | `parse`·`parseEvent` 결과 → `.ics` 문자열 (`title`, `timezone`, `durationMinutes`) |
234
193
 
235
- | | 예제 | 실행 |
236
- |---|---|---|
237
- | ✅ | [**할 일 빠른 입력**](./examples/quick-add.mjs)<br>`parseEvent`로 "다음주 금요일까지 보고서 제출" → 제목 "보고서 제출" + 마감 11/6 23:59 | `node examples/quick-add.mjs "..."` |
238
- | 💬 | [**Slack 봇**](./examples/slack-bot)<br>채널 메시지에서 날짜를 찾아 스레드로 알려줌 (UTC 서버 + `timezone`) | `node examples/slack-bot/try.mjs` |
239
- | 🧪 | [**Playground**](./playground/index.html)<br>브라우저에서 직접 입력해 보기 | `npm run playground` |
194
+ <details>
195
+ <summary><b><code>ParseResult</code>와 옵션</b></summary>
240
196
 
241
197
  <br>
242
198
 
243
- ## LLM 대신 쓰는 이유
244
-
245
- 날짜를 LLM에 물어볼 수도 있지만, 정해진 규칙으로 풀리는 문제라면 이쪽이 빠르고 공짜이고 매번 같은 답을 냅니다.
199
+ | `ParseResult` | |
200
+ |---|---|
201
+ | `date` | 찾은 시점. 구간이면 시작, 마감이면 기준 시각 |
202
+ | `end` | 구간의 끝 또는 마감. 없으면 `undefined` |
203
+ | `text` | 문장에서 찾은 부분 |
204
+ | `index` | 문장 안에서의 위치 |
205
+ | `hasTime` | 시각까지 말했는지. `false`면 `date`는 그날 `defaultHour`시 |
206
+ | `recurrence` | 반복 일정이면 `{ freq, interval, byWeekday, …, until?, count?, rrule }` |
207
+ | `ambiguity` | 해석이 갈리면 `{ reason, alternatives }` |
246
208
 
247
- 메신저 문장 112개로 측정한 결과 ([자세히](./bench/RESULTS.md), Gemini Flash-Lite **무료 티어**, 2026-10-31):
209
+ | `ambiguity.reason` | 언제 |
210
+ |---|---|
211
+ | `meridiem` | 오전/오후 없는 1\~12시 ("3시", "내일 7시") |
212
+ | `next-weekday` | "다음 토요일"이 이번 주일 수도, 다음 주일 수도 |
213
+ | `approximate-time` | "저녁"(19시)처럼 관례로 정한 시각 |
248
214
 
249
- | | ko-chrono | LLM API (Gemini Flash-Lite) |
215
+ | 옵션 | 기본값 | 설명 |
250
216
  |---|---|---|
251
- | 지연 | 문장당 **0.017ms** | 문장당 **987ms** (네트워크만 40ms 이상) |
252
- | 비용 | **0** | 토큰당 과금 (무료 티어는 분당·일일 한도) |
253
- | 같은 문장 → 같은 답 | **항상** | 보장 안 됨 |
254
- | 오프라인 · 브라우저 | **됨** | 안 됨 |
255
- | 시제·문맥 ("깨서 못 잤어") | 모름 | 앎 |
256
- | 처음 보는 표현 | 못 함 (`null`) | 대체로 함 |
217
+ | `referenceDate` | `new Date()` | "내일", "다음주"의 기준 시각 |
218
+ | `timezone` | 실행 환경 | 어느 시간대의 시각으로 볼지 (`'Asia/Seoul'` 같은 IANA 이름) |
219
+ | `weekStart` | `'monday'` | 한 주의 시작 (`'sunday'` 가능) |
220
+ | `defaultHour` | `0` | 시각 없는 날짜에 넣을 시 |
257
221
 
258
- 유료 티어나 다른 모델이면 LLM 쪽이 더 빠를 수 있지만, 네트워크 왕복만으로도 ko-chrono보다 수천 배 느립니다. 정확도는 비슷한 수준이고 차이의 절반은 "저녁이 몇 시냐" 같은 관례 차이였습니다. 규칙으로 풀리는 날짜는 ko-chrono로, `null`이 나오거나 `ambiguity`가 있는 문장만 LLM으로 보내는 조합을 권합니다.
222
+ </details>
259
223
 
260
- <br>
224
+ ## 예제
225
+
226
+ - [할 일 빠른 입력](./examples/quick-add.mjs) — `node examples/quick-add.mjs "다음주 금요일까지 보고서 제출"`
227
+ - [Slack 봇](./examples/slack-bot) — 채널 메시지에서 날짜를 찾아 스레드로 알려 줌
228
+ - [Playground](https://jwn7.github.io/ko-chrono/) — 브라우저에서 직접 입력. 로컬은 `npm run playground`
229
+
230
+ ## LLM에 물어보면 되지 않나요?
261
231
 
262
- ## 로드맵
232
+ 됩니다. 다만 규칙으로 풀리는 날짜라면 이쪽이 빠르고, 공짜이고, 매번 같은 답을 냅니다.
233
+ 메신저 문장 112개로 비교해 보니 ko-chrono는 문장당 **0.017ms**, Gemini Flash-Lite(무료 티어)는 **987ms**였습니다 ([자세히](./bench/RESULTS.md)).
263
234
 
264
- - [x] 반복 일정 (매주 월요일 9시, 격주) → RRULE
265
- - [x] 제목 분리 (`parseEvent`)
266
- - [x] 명절·공휴일 (설 연휴, 추석 전날, 크리스마스) — 음력 명절은 한국천문연구원 자료가 있는 2025\~2028년
267
- - [x] 일반 음력 날짜 (음력 3월 3일) — 한국천문연구원 자료가 있는 2025\~2028년
268
- - [x] 모호성 정보 (`ambiguity`)
269
- - [ ] chrono-node `ko` 로케일 기여 — 첫 PR 분량 준비됨 ([contrib/chrono-ko](./contrib/chrono-ko))
235
+ 평소엔 ko-chrono로 처리하고, `null`이 나오거나 `ambiguity`가 있는 문장만 LLM에 보내는 조합을 권합니다.
270
236
 
271
237
  ## 기여
272
238
 
273
- 가장 큰 도움은 **실제로 쓰이는 문장**입니다. 인식이 안 되거나 다르게 해석되는 문장을 [이슈로 제보](https://github.com/jwn7/ko-chrono/issues/new?template=sentence.yml)해 주세요.
239
+ 가장 큰 도움은 **실제로 쓰이는 문장**입니다. 인식이 안 되거나 다르게 읽히는 문장을 [이슈로 제보](https://github.com/jwn7/ko-chrono/issues/new?template=sentence.yml)해 주세요.
274
240
 
275
241
  ```bash
276
242
  npm install
277
243
  npm test
278
- npm run build
279
244
  ```
280
245
 
281
246
  ## 라이선스
@@ -0,0 +1,30 @@
1
+ import type { ParseResult } from './types.js';
2
+ import type { ParsedEvent } from './event.js';
3
+ export interface ICSOptions {
4
+ /** 일정 제목. 기본값: parseEvent 결과면 그 title, parse 결과면 문장에서 찾은 부분 */
5
+ title?: string;
6
+ /**
7
+ * 시각을 이 시간대의 벽시계로 쓴다 (`DTSTART;TZID=Asia/Seoul:…`). 서머타임이 있는 곳의 반복 일정은 이걸 넘겨야
8
+ * 시각이 유지된다. 없으면 UTC로 쓴다. 종일 일정의 날짜도 이 시간대 기준.
9
+ */
10
+ timezone?: string;
11
+ /** 시각은 있는데 끝이 없을 때 일정 길이(분). 기본값: 60 */
12
+ durationMinutes?: number;
13
+ /** VEVENT의 UID. 기본값: 무작위 */
14
+ uid?: string;
15
+ /** DTSTAMP. 기본값: new Date() */
16
+ now?: Date;
17
+ }
18
+ /**
19
+ * parse / parseEvent 결과를 iCalendar(.ics) 문자열로 바꾼다. 날짜가 없으면 null.
20
+ * Google·Apple·Outlook 캘린더에서 그대로 가져올 수 있다.
21
+ *
22
+ * - 시각이 있으면 DTSTART/DTEND (끝이 없으면 durationMinutes 뒤)
23
+ * - 시각이 없으면 종일 일정 (`VALUE=DATE`, DTEND는 마지막 날의 다음날)
24
+ * - 반복이면 RRULE. UNTIL은 DTSTART 형식에 맞춰 UTC 또는 날짜로 다시 쓴다
25
+ *
26
+ * @example
27
+ * const ics = toICS(parseEvent('다음주 월요일 9시 팀 회의'), { timezone: 'Asia/Seoul' });
28
+ * // BEGIN:VCALENDAR … SUMMARY:팀 회의 … DTSTART;TZID=Asia/Seoul:20261102T090000 …
29
+ */
30
+ export declare function toICS(input: ParseResult | ParsedEvent, options?: ICSOptions): string | null;
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toICS = toICS;
4
+ const recurrence_js_1 = require("./parsers/recurrence.js");
5
+ const timezone_js_1 = require("./util/timezone.js");
6
+ const pad = (n) => String(n).padStart(2, '0');
7
+ const ymd = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
8
+ const hms = (d) => `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
9
+ const utc = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T` +
10
+ `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
11
+ /** TEXT 값 이스케이프 (RFC 5545 3.3.11) */
12
+ const escapeText = (s) => s.replace(/[\\;,]/g, (c) => `\\${c}`).replace(/\r?\n/g, '\\n');
13
+ /** 75바이트가 넘는 줄은 CRLF + 공백으로 접는다 (RFC 5545 3.1). UTF-8 글자 중간에서는 끊지 않는다 */
14
+ function fold(line) {
15
+ const out = [];
16
+ let cur = '';
17
+ let bytes = 0;
18
+ for (const ch of line) {
19
+ const cp = ch.codePointAt(0);
20
+ const len = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
21
+ // 이어지는 줄은 앞의 공백 1바이트를 뺀 74바이트까지
22
+ if (bytes + len > (out.length ? 74 : 75)) {
23
+ out.push(cur);
24
+ cur = '';
25
+ bytes = 0;
26
+ }
27
+ cur += ch;
28
+ bytes += len;
29
+ }
30
+ out.push(cur);
31
+ return out.join('\r\n ');
32
+ }
33
+ /**
34
+ * parse / parseEvent 결과를 iCalendar(.ics) 문자열로 바꾼다. 날짜가 없으면 null.
35
+ * Google·Apple·Outlook 캘린더에서 그대로 가져올 수 있다.
36
+ *
37
+ * - 시각이 있으면 DTSTART/DTEND (끝이 없으면 durationMinutes 뒤)
38
+ * - 시각이 없으면 종일 일정 (`VALUE=DATE`, DTEND는 마지막 날의 다음날)
39
+ * - 반복이면 RRULE. UNTIL은 DTSTART 형식에 맞춰 UTC 또는 날짜로 다시 쓴다
40
+ *
41
+ * @example
42
+ * const ics = toICS(parseEvent('다음주 월요일 9시 팀 회의'), { timezone: 'Asia/Seoul' });
43
+ * // BEGIN:VCALENDAR … SUMMARY:팀 회의 … DTSTART;TZID=Asia/Seoul:20261102T090000 …
44
+ */
45
+ function toICS(input, options = {}) {
46
+ if (!input.date)
47
+ return null;
48
+ const tz = options.timezone;
49
+ const wall = (d) => (tz ? (0, timezone_js_1.toWallClock)(d, tz) : d);
50
+ const title = options.title ?? ('title' in input ? input.title : input.text);
51
+ const lines = [
52
+ 'BEGIN:VCALENDAR',
53
+ 'VERSION:2.0',
54
+ 'PRODID:-//ko-chrono//KO',
55
+ 'CALSCALE:GREGORIAN',
56
+ 'BEGIN:VEVENT',
57
+ `UID:${options.uid ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}@ko-chrono`}`,
58
+ `DTSTAMP:${utc(options.now ?? new Date())}`,
59
+ `SUMMARY:${escapeText(title)}`,
60
+ ];
61
+ if (input.hasTime) {
62
+ const end = input.end ?? new Date(input.date.getTime() + (options.durationMinutes ?? 60) * 60_000);
63
+ const at = (name, d) => (tz ? `${name};TZID=${tz}:${ymd(wall(d))}T${hms(wall(d))}` : `${name}:${utc(d)}`);
64
+ lines.push(at('DTSTART', input.date), at('DTEND', end));
65
+ }
66
+ else {
67
+ const first = wall(input.date);
68
+ const last = input.end ? wall(input.end) : first;
69
+ const after = new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1);
70
+ lines.push(`DTSTART;VALUE=DATE:${ymd(first)}`, `DTEND;VALUE=DATE:${ymd(after)}`);
71
+ }
72
+ const r = input.recurrence;
73
+ if (r) {
74
+ // recurrence.rrule의 UNTIL은 벽시계(floating) 형식이라, 시각이 있는 일정이면 UTC로 바꿔 쓴다
75
+ const rrule = r.until && input.hasTime ? (0, recurrence_js_1.toRRule)({ ...r, until: undefined }) + `;UNTIL=${utc(r.until)}` : r.rrule;
76
+ lines.push(`RRULE:${rrule}`);
77
+ }
78
+ lines.push('END:VEVENT', 'END:VCALENDAR', '');
79
+ return lines.map(fold).join('\r\n');
80
+ }