ko-chrono 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -66
- package/dist/cjs/event.d.ts +18 -0
- package/dist/cjs/event.js +31 -0
- package/dist/cjs/index.d.ts +55 -0
- package/dist/cjs/index.js +189 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/parsers/absoluteDate.d.ts +3 -0
- package/dist/cjs/parsers/absoluteDate.js +95 -0
- package/dist/cjs/parsers/duration.d.ts +3 -0
- package/dist/cjs/parsers/duration.js +45 -0
- package/dist/cjs/parsers/holiday.d.ts +3 -0
- package/dist/cjs/parsers/holiday.js +64 -0
- package/dist/cjs/parsers/offset.d.ts +3 -0
- package/dist/cjs/parsers/offset.js +64 -0
- package/dist/cjs/parsers/recurrence.d.ts +5 -0
- package/dist/cjs/parsers/recurrence.js +117 -0
- package/dist/cjs/parsers/relativeDay.d.ts +3 -0
- package/dist/cjs/parsers/relativeDay.js +26 -0
- package/dist/cjs/parsers/relativeMonth.d.ts +3 -0
- package/dist/cjs/parsers/relativeMonth.js +30 -0
- package/dist/cjs/parsers/relativeYear.d.ts +3 -0
- package/dist/cjs/parsers/relativeYear.js +30 -0
- package/dist/cjs/parsers/time.d.ts +3 -0
- package/dist/cjs/parsers/time.js +149 -0
- package/dist/cjs/parsers/weekday.d.ts +3 -0
- package/dist/cjs/parsers/weekday.js +109 -0
- package/dist/cjs/resolve.d.ts +21 -0
- package/dist/cjs/resolve.js +414 -0
- package/dist/cjs/types.d.ts +105 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/util/lunarHolidays.d.ts +3 -0
- package/dist/cjs/util/lunarHolidays.js +16 -0
- package/dist/cjs/util/number.d.ts +5 -0
- package/dist/cjs/util/number.js +42 -0
- package/dist/cjs/util/timezone.d.ts +4 -0
- package/dist/cjs/util/timezone.js +55 -0
- package/dist/event.d.ts +18 -0
- package/dist/event.js +28 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +51 -6
- package/dist/parsers/absoluteDate.js +6 -2
- package/dist/parsers/duration.js +4 -2
- package/dist/parsers/holiday.d.ts +3 -0
- package/dist/parsers/holiday.js +60 -0
- package/dist/parsers/offset.js +6 -3
- package/dist/parsers/recurrence.d.ts +5 -0
- package/dist/parsers/recurrence.js +112 -0
- package/dist/parsers/time.js +19 -2
- package/dist/parsers/weekday.js +10 -0
- package/dist/resolve.d.ts +2 -2
- package/dist/resolve.js +132 -13
- package/dist/types.d.ts +33 -0
- package/dist/util/lunarHolidays.d.ts +3 -0
- package/dist/util/lunarHolidays.js +13 -0
- package/dist/util/timezone.d.ts +4 -0
- package/dist/util/timezone.js +51 -0
- package/package.json +13 -5
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.groupFragments = groupFragments;
|
|
4
|
+
exports.merge = merge;
|
|
5
|
+
exports.isTimeOnly = isTimeOnly;
|
|
6
|
+
exports.isWeekdayOnly = isWeekdayOnly;
|
|
7
|
+
exports.isDayOnly = isDayOnly;
|
|
8
|
+
exports.alignEnd = alignEnd;
|
|
9
|
+
exports.resolve = resolve;
|
|
10
|
+
const recurrence_js_1 = require("./parsers/recurrence.js");
|
|
11
|
+
const lunarHolidays_js_1 = require("./util/lunarHolidays.js");
|
|
12
|
+
function startOfWeek(d, weekStart) {
|
|
13
|
+
const r = new Date(d);
|
|
14
|
+
r.setHours(0, 0, 0, 0);
|
|
15
|
+
const dow = r.getDay(); // 0=일
|
|
16
|
+
const diff = weekStart === 'monday' ? (dow + 6) % 7 : dow;
|
|
17
|
+
r.setDate(r.getDate() - diff);
|
|
18
|
+
return r;
|
|
19
|
+
}
|
|
20
|
+
function lastDayOfMonth(y, m0) {
|
|
21
|
+
return new Date(y, m0 + 1, 0).getDate();
|
|
22
|
+
}
|
|
23
|
+
/** 그 달의 N번째 요일이 며칠인지. nth = -1이면 마지막. 없으면 null ("다섯째 주 토요일") */
|
|
24
|
+
function nthWeekdayOfMonth(y, m0, weekday, nth) {
|
|
25
|
+
const last = lastDayOfMonth(y, m0);
|
|
26
|
+
let day = 1 + ((weekday - new Date(y, m0, 1).getDay() + 7) % 7);
|
|
27
|
+
if (nth > 0)
|
|
28
|
+
day += (nth - 1) * 7;
|
|
29
|
+
else
|
|
30
|
+
while (day + 7 <= last)
|
|
31
|
+
day += 7;
|
|
32
|
+
return day > last ? null : day;
|
|
33
|
+
}
|
|
34
|
+
/** 규칙 20: 오전/오후 없는 1~6시는 오후 ("3시에 보자" → 15시). 7~11시는 그대로 */
|
|
35
|
+
function clockHour(c) {
|
|
36
|
+
const h = c.hour;
|
|
37
|
+
return !c.meridiem && h >= 1 && h <= 6 ? h + 12 : h;
|
|
38
|
+
}
|
|
39
|
+
/** 기준일의 "일"을 유지하되 그 달에 없으면 말일로 (2월 29일 + 1년 → 2월 28일) */
|
|
40
|
+
function sameDayIn(y, m0, day) {
|
|
41
|
+
return new Date(y, m0, Math.min(day, lastDayOfMonth(y, m0)));
|
|
42
|
+
}
|
|
43
|
+
// 조각 사이에 와도 같은 표현으로 묶는 것: "내일 오후", "내일은 오후", "토요일쯤 저녁", "모레도 2시"
|
|
44
|
+
// "이나/아니면/랑"은 다른 선택지일 수 있어 묶지 않는다.
|
|
45
|
+
const JOINER = /^(?:\s|,|에|의|엔|은|는|도|쯤|경|께|부터)*$/;
|
|
46
|
+
/**
|
|
47
|
+
* 인접한 조각(fragment)을 하나로 합친다.
|
|
48
|
+
* 예: "다음주 화요일" + "오후 3시" → 하나의 결과.
|
|
49
|
+
* 인접 기준: 사이에 공백/쉼표/조사(에,의,엔,은,는,도)/어림말(쯤,경,께)만 있을 때.
|
|
50
|
+
*/
|
|
51
|
+
function groupFragments(fragments, text) {
|
|
52
|
+
const sorted = [...fragments].sort((a, b) => a.index - b.index);
|
|
53
|
+
const groups = [];
|
|
54
|
+
for (const f of sorted) {
|
|
55
|
+
const g = groups[groups.length - 1];
|
|
56
|
+
if (g) {
|
|
57
|
+
const last = g[g.length - 1];
|
|
58
|
+
const between = text.slice(last.index + last.text.length, f.index);
|
|
59
|
+
const conflict = hasConflict(merge(g.map((x) => x.components)), f.components);
|
|
60
|
+
if (JOINER.test(between) && !conflict) {
|
|
61
|
+
g.push(f);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
groups.push([f]);
|
|
66
|
+
}
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
|
69
|
+
function merge(list) {
|
|
70
|
+
return Object.assign({}, ...list);
|
|
71
|
+
}
|
|
72
|
+
const DATE_KEYS = [
|
|
73
|
+
'year', 'yearOffset', 'month', 'day', 'dayOffset', 'weekOffset', 'weekday', 'monthOffset', 'monthPart',
|
|
74
|
+
];
|
|
75
|
+
/** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
|
|
76
|
+
function isTimeOnly(c) {
|
|
77
|
+
return (c.hour !== undefined &&
|
|
78
|
+
c.hourOffset === undefined &&
|
|
79
|
+
c.minuteOffset === undefined &&
|
|
80
|
+
!DATE_KEYS.some((k) => c[k] !== undefined));
|
|
81
|
+
}
|
|
82
|
+
/** 날짜 정보가 요일뿐인 조각인지 ("금요일까지") */
|
|
83
|
+
function isWeekdayOnly(c) {
|
|
84
|
+
return c.weekday !== undefined && DATE_KEYS.every((k) => k === 'weekday' || c[k] === undefined);
|
|
85
|
+
}
|
|
86
|
+
/** 날짜 정보가 "N일"뿐인 조각인지 ("3일까지") */
|
|
87
|
+
function isDayOnly(c) {
|
|
88
|
+
return c.day !== undefined && DATE_KEYS.every((k) => k === 'day' || c[k] === undefined);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 시 범위의 끝을 시작에 맞춘다 (규칙 16).
|
|
92
|
+
* 끝을 시작과 같은 날에 놓고, 시작보다 이르면 오전/오후를 이어받거나(+12h) 다음날로 넘긴다.
|
|
93
|
+
* "오후 3~5시" → 17시, "오후 11시부터 1시까지" → 다음날 1시, "오후 3시부터 오전 1시까지" → 다음날 1시
|
|
94
|
+
*/
|
|
95
|
+
function alignEnd(start, end) {
|
|
96
|
+
const hour = end.hour;
|
|
97
|
+
const r = new Date(start);
|
|
98
|
+
r.setHours(hour, end.minute ?? 0, 0, 0);
|
|
99
|
+
if (r <= start) {
|
|
100
|
+
if (!end.meridiem && hour < 12 && hour + 12 > start.getHours())
|
|
101
|
+
r.setHours(hour + 12);
|
|
102
|
+
else
|
|
103
|
+
r.setDate(r.getDate() + 1);
|
|
104
|
+
}
|
|
105
|
+
return r;
|
|
106
|
+
}
|
|
107
|
+
/** 같은 종류의 정보가 두 번 나오면 다른 날짜 표현으로 본다 (예: "3일 5일"). */
|
|
108
|
+
function hasConflict(a, b) {
|
|
109
|
+
const dayKeys = ['day', 'dayOffset', 'weekday', 'monthPart'];
|
|
110
|
+
const timeKeys = ['hour'];
|
|
111
|
+
const both = (keys) => keys.some((k) => a[k] !== undefined) && keys.some((k) => b[k] !== undefined);
|
|
112
|
+
// 규칙 15: "내일" + "30분 후"는 서로 다른 표현
|
|
113
|
+
const dateKeys = DATE_KEYS;
|
|
114
|
+
const relKeys = ['hourOffset', 'minuteOffset'];
|
|
115
|
+
const has = (x, keys) => keys.some((k) => x[k] !== undefined);
|
|
116
|
+
const dateVsRel = (has(a, dateKeys) && has(b, relKeys)) || (has(a, relKeys) && has(b, dateKeys));
|
|
117
|
+
// "11월 5일 목요일": 날짜 뒤의 요일은 덧붙이는 말이라 충돌이 아니다
|
|
118
|
+
const weekdayNote = (x, y) => x.day !== undefined && y.weekday !== undefined && !y.weekend &&
|
|
119
|
+
y.weekOffset === undefined && y.day === undefined && y.dayOffset === undefined && y.monthPart === undefined;
|
|
120
|
+
const dayConflict = both(dayKeys) && !weekdayNote(a, b) && !weekdayNote(b, a);
|
|
121
|
+
return dayConflict || both(timeKeys) || dateVsRel;
|
|
122
|
+
}
|
|
123
|
+
function resolve(group, opts) {
|
|
124
|
+
const c = merge(group.map((f) => f.components));
|
|
125
|
+
const ref = opts.referenceDate;
|
|
126
|
+
// "내일 이 시간" → 기준 시각의 시:분. "다음 주 이 시간"은 7일 뒤 (그 주 월요일이 아니라)
|
|
127
|
+
if (c.sameTime && c.hour === undefined) {
|
|
128
|
+
c.hour = ref.getHours();
|
|
129
|
+
c.minute = ref.getMinutes();
|
|
130
|
+
c.meridiem = true;
|
|
131
|
+
if (c.weekOffset !== undefined && c.weekday === undefined)
|
|
132
|
+
c.sameDay = true;
|
|
133
|
+
}
|
|
134
|
+
if (c.recur)
|
|
135
|
+
return resolveRecurrence(group, c, opts);
|
|
136
|
+
let d = new Date(ref);
|
|
137
|
+
d.setSeconds(0, 0);
|
|
138
|
+
let dateSet = false;
|
|
139
|
+
const hasTime = c.hour !== undefined || c.hourOffset !== undefined || c.minuteOffset !== undefined;
|
|
140
|
+
// 0. 음력 명절 (규칙 29): 연도를 말했으면 그 해, 아니면 올해 것이 (연휴까지) 지났을 때 내년.
|
|
141
|
+
// 공식 자료가 없는 연도는 null
|
|
142
|
+
const hasYear = c.year !== undefined || c.yearOffset !== undefined;
|
|
143
|
+
if (c.lunar) {
|
|
144
|
+
const said = c.year ?? (c.yearOffset !== undefined ? ref.getFullYear() + c.yearOffset : undefined);
|
|
145
|
+
const tail = (c.dayOffset ?? 0) + (c.durationDays ? c.durationDays - 1 : 0);
|
|
146
|
+
const years = said !== undefined ? [said] : [ref.getFullYear(), ref.getFullYear() + 1];
|
|
147
|
+
let found = null;
|
|
148
|
+
for (const y of years) {
|
|
149
|
+
const md = lunarHolidays_js_1.LUNAR_HOLIDAYS[y]?.[c.lunar];
|
|
150
|
+
if (!md)
|
|
151
|
+
break;
|
|
152
|
+
const lastMoment = new Date(y, md[0] - 1, md[1] + tail, 23, 59);
|
|
153
|
+
if (said !== undefined || lastMoment >= ref) {
|
|
154
|
+
found = new Date(y, md[0] - 1, md[1]);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!found)
|
|
159
|
+
return null;
|
|
160
|
+
d = found;
|
|
161
|
+
dateSet = true;
|
|
162
|
+
}
|
|
163
|
+
// 1. 절대 날짜
|
|
164
|
+
if (!c.lunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
|
|
165
|
+
let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
|
|
166
|
+
// "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
|
|
167
|
+
const m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
|
|
168
|
+
// 연도 미지정 + 이미 지났으면 내년으로.
|
|
169
|
+
// 월일("3월 1일")은 그 날이, 월("3월", "2월 말")은 그 달이 통째로 지났을 때.
|
|
170
|
+
if (!hasYear && c.month !== undefined) {
|
|
171
|
+
const lastDay = c.day ?? lastDayOfMonth(y, m0);
|
|
172
|
+
if (new Date(y, m0, lastDay, 23, 59) < ref)
|
|
173
|
+
y += 1;
|
|
174
|
+
}
|
|
175
|
+
let day = c.day ?? (c.month !== undefined || (hasYear && !c.sameDay) ? 1 : ref.getDate());
|
|
176
|
+
if (c.monthPart)
|
|
177
|
+
day = partDay(y, m0, c.monthPart);
|
|
178
|
+
if (c.sameDay)
|
|
179
|
+
day = Math.min(day, lastDayOfMonth(y, m0));
|
|
180
|
+
// 2월 30일, 11월 31일처럼 없는 날짜는 다음 달로 넘기지 않고 버린다
|
|
181
|
+
if (day > lastDayOfMonth(y, m0))
|
|
182
|
+
return null;
|
|
183
|
+
d = new Date(y, m0, day);
|
|
184
|
+
dateSet = true;
|
|
185
|
+
}
|
|
186
|
+
// 2. 상대 월
|
|
187
|
+
if (c.monthOffset !== undefined) {
|
|
188
|
+
const y = ref.getFullYear();
|
|
189
|
+
const m0 = ref.getMonth() + c.monthOffset;
|
|
190
|
+
const day = c.monthPart ? partDay(y, m0, c.monthPart) : c.day ?? 1;
|
|
191
|
+
if (day > lastDayOfMonth(y, m0))
|
|
192
|
+
return null;
|
|
193
|
+
// "다음달 이맘때" → 오늘 날짜 유지
|
|
194
|
+
d = c.sameDay ? sameDayIn(y, m0, ref.getDate()) : new Date(y, m0, day);
|
|
195
|
+
dateSet = true;
|
|
196
|
+
}
|
|
197
|
+
// 3. N째 주 (규칙 22): 달은 1·2단계에서 정해진 달, 없으면 기준 달
|
|
198
|
+
if (c.nthWeek !== undefined) {
|
|
199
|
+
const y = dateSet ? d.getFullYear() : ref.getFullYear();
|
|
200
|
+
const m0 = dateSet ? d.getMonth() : ref.getMonth();
|
|
201
|
+
const target = c.weekday ?? (opts.weekStart === 'monday' ? 1 : 0);
|
|
202
|
+
const day = nthWeekdayOfMonth(y, m0, target, c.nthWeek);
|
|
203
|
+
// "11월 다섯째 주 토요일"처럼 없는 날짜는 null
|
|
204
|
+
if (day === null)
|
|
205
|
+
return null;
|
|
206
|
+
d = new Date(y, m0, day);
|
|
207
|
+
dateSet = true;
|
|
208
|
+
}
|
|
209
|
+
else if (c.weekday !== undefined && c.weekOffset === undefined && c.day !== undefined) {
|
|
210
|
+
// 4. 주 + 요일. "11월 5일 목요일": 날짜가 우선. 요일이 안 맞으면 어느 쪽이 맞는지 모르므로 null
|
|
211
|
+
if (d.getDay() !== c.weekday)
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
else if (c.weekOffset !== undefined || c.weekday !== undefined) {
|
|
215
|
+
if (c.weekOffset !== undefined && c.sameDay) {
|
|
216
|
+
// "다음주 이맘때" → 7일 뒤
|
|
217
|
+
d = new Date(ref);
|
|
218
|
+
d.setHours(0, 0, 0, 0);
|
|
219
|
+
d.setDate(d.getDate() + c.weekOffset * 7);
|
|
220
|
+
}
|
|
221
|
+
else if (c.weekOffset !== undefined) {
|
|
222
|
+
const sow = startOfWeek(ref, opts.weekStart);
|
|
223
|
+
sow.setDate(sow.getDate() + c.weekOffset * 7);
|
|
224
|
+
if (c.weekday !== undefined) {
|
|
225
|
+
const offsetInWeek = opts.weekStart === 'monday' ? (c.weekday + 6) % 7 : c.weekday;
|
|
226
|
+
sow.setDate(sow.getDate() + offsetInWeek);
|
|
227
|
+
}
|
|
228
|
+
d = sow;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// 요일 단독: 오늘 포함, 가장 가까운 미래의 그 요일
|
|
232
|
+
const today = new Date(ref);
|
|
233
|
+
today.setHours(0, 0, 0, 0);
|
|
234
|
+
const dow = today.getDay();
|
|
235
|
+
// "주말" 단독인데 오늘이 일요일이면 오늘
|
|
236
|
+
let diff = c.weekend && dow === 0 ? 0 : (c.weekday - dow + 7) % 7;
|
|
237
|
+
// "다음 금요일"은 오늘 제외 미래, "지난 금요일"은 오늘 제외 과거
|
|
238
|
+
if (c.weekdayDir === 'next' && diff === 0)
|
|
239
|
+
diff = 7;
|
|
240
|
+
if (c.weekdayDir === 'past')
|
|
241
|
+
diff = diff === 0 ? -7 : diff - 7;
|
|
242
|
+
today.setDate(today.getDate() + diff);
|
|
243
|
+
d = today;
|
|
244
|
+
}
|
|
245
|
+
dateSet = true;
|
|
246
|
+
}
|
|
247
|
+
// 5. 일 단위 상대 이동
|
|
248
|
+
if (c.dayOffset !== undefined) {
|
|
249
|
+
if (!dateSet) {
|
|
250
|
+
d = new Date(ref);
|
|
251
|
+
d.setHours(0, 0, 0, 0);
|
|
252
|
+
}
|
|
253
|
+
d.setDate(d.getDate() + c.dayOffset);
|
|
254
|
+
dateSet = true;
|
|
255
|
+
}
|
|
256
|
+
// 6. 시각
|
|
257
|
+
if (c.hour !== undefined) {
|
|
258
|
+
const hour = clockHour(c);
|
|
259
|
+
if (!dateSet) {
|
|
260
|
+
d = new Date(ref);
|
|
261
|
+
// 시간만 있고 날짜가 없으면: 오늘, 이미 지났으면 내일
|
|
262
|
+
d.setHours(hour, c.minute ?? 0, 0, 0);
|
|
263
|
+
if (d < ref)
|
|
264
|
+
d.setDate(d.getDate() + 1);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
d.setHours(hour, c.minute ?? 0, 0, 0);
|
|
268
|
+
// "내일 자정" = 내일이 끝나는 시점
|
|
269
|
+
if (c.midnight)
|
|
270
|
+
d.setDate(d.getDate() + 1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else if (c.hourOffset !== undefined || c.minuteOffset !== undefined) {
|
|
274
|
+
d = new Date(ref);
|
|
275
|
+
d.setSeconds(0, 0);
|
|
276
|
+
if (c.hourOffset)
|
|
277
|
+
d.setHours(d.getHours() + c.hourOffset);
|
|
278
|
+
if (c.minuteOffset)
|
|
279
|
+
d.setMinutes(d.getMinutes() + c.minuteOffset);
|
|
280
|
+
}
|
|
281
|
+
else if (dateSet) {
|
|
282
|
+
d.setHours(opts.defaultHour, 0, 0, 0);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
// 안전장치: 어떤 경로로든 계산이 깨진 날짜는 내보내지 않는다
|
|
288
|
+
if (Number.isNaN(d.getTime()))
|
|
289
|
+
return null;
|
|
290
|
+
const first = group[0];
|
|
291
|
+
const last = group[group.length - 1];
|
|
292
|
+
// text는 parseAll()에서 원문 slice로 채운다. _end는 그때 쓰는 내부 값.
|
|
293
|
+
const result = { date: d, text: '', index: first.index, hasTime };
|
|
294
|
+
result._end = last.index + last.text.length;
|
|
295
|
+
// "오늘부터 3일간", "3시부터 2시간 동안": 시작일을 포함한 기간의 끝
|
|
296
|
+
if (c.durationDays !== undefined || c.durationMonths !== undefined || c.durationMinutes !== undefined) {
|
|
297
|
+
if (!dateSet && !hasTime)
|
|
298
|
+
return null;
|
|
299
|
+
const end = new Date(d);
|
|
300
|
+
if (c.durationMinutes !== undefined) {
|
|
301
|
+
end.setMinutes(end.getMinutes() + c.durationMinutes);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
if (c.durationMonths !== undefined)
|
|
305
|
+
end.setMonth(end.getMonth() + c.durationMonths);
|
|
306
|
+
if (c.durationDays !== undefined)
|
|
307
|
+
end.setDate(end.getDate() + c.durationDays);
|
|
308
|
+
end.setDate(end.getDate() - 1);
|
|
309
|
+
end.setHours(23, 59, 0, 0);
|
|
310
|
+
}
|
|
311
|
+
result.end = end;
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
if (c.isEnd || c.whole) {
|
|
315
|
+
// "~까지", "~이내": 시작은 기준 시각, 끝은 해당 시점(시간 없으면 그날 23:59)
|
|
316
|
+
// "~내내": 시작은 그 기간의 시작, 끝은 기간의 끝
|
|
317
|
+
result.end = hasTime ? new Date(d) : periodEnd(d, c);
|
|
318
|
+
// 규칙 30: "어제까지"처럼 이미 지난 마감은 구간이 아니라 그 시점 (date = end)
|
|
319
|
+
if (c.isEnd)
|
|
320
|
+
result.date = result.end < ref ? new Date(result.end) : new Date(ref);
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
/** 날짜 표현이 가리키는 기간의 마지막 시각. "이번 주" → 일요일, "이번달" → 말일, "올해" → 12/31, 그 외는 그날 23:59 */
|
|
325
|
+
function periodEnd(d, c) {
|
|
326
|
+
let end = new Date(d);
|
|
327
|
+
if (c.weekOffset !== undefined && c.weekday === undefined)
|
|
328
|
+
end.setDate(end.getDate() + 6);
|
|
329
|
+
else if (c.monthOffset !== undefined && c.monthPart === undefined && c.day === undefined)
|
|
330
|
+
end = new Date(end.getFullYear(), end.getMonth(), lastDayOfMonth(end.getFullYear(), end.getMonth()));
|
|
331
|
+
else if ((c.year !== undefined || c.yearOffset !== undefined) && c.month === undefined && !c.sameDay)
|
|
332
|
+
end = new Date(end.getFullYear(), 11, 31);
|
|
333
|
+
end.setHours(23, 59, 0, 0);
|
|
334
|
+
return end;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* 반복 일정 (규칙 27): date = 기준 시각 이후 첫 발생.
|
|
338
|
+
* 시각이 있으면 지난 시각은 건너뛰고, 날짜만 있으면 오늘도 포함한다.
|
|
339
|
+
* "매달 31일"은 31일이 없는 달을 건너뛴다 (RRULE과 같은 동작).
|
|
340
|
+
*/
|
|
341
|
+
function resolveRecurrence(group, c, opts) {
|
|
342
|
+
const r = c.recur;
|
|
343
|
+
const ref = opts.referenceDate;
|
|
344
|
+
const hasTime = c.hour !== undefined;
|
|
345
|
+
const today = new Date(ref);
|
|
346
|
+
today.setHours(0, 0, 0, 0);
|
|
347
|
+
const at = (day) => {
|
|
348
|
+
const x = new Date(day);
|
|
349
|
+
x.setHours(hasTime ? clockHour(c) : opts.defaultHour, hasTime ? c.minute ?? 0 : 0, 0, 0);
|
|
350
|
+
return x;
|
|
351
|
+
};
|
|
352
|
+
const ok = (x) => (hasTime ? x >= ref : x >= today);
|
|
353
|
+
let d = null;
|
|
354
|
+
if (r.freq === 'MINUTELY' || r.freq === 'HOURLY') {
|
|
355
|
+
d = new Date(ref);
|
|
356
|
+
d.setSeconds(0, 0);
|
|
357
|
+
d.setMinutes(d.getMinutes() + r.interval * (r.freq === 'HOURLY' ? 60 : 1));
|
|
358
|
+
}
|
|
359
|
+
else if (r.freq === 'DAILY') {
|
|
360
|
+
d = at(today);
|
|
361
|
+
if (!ok(d))
|
|
362
|
+
d.setDate(d.getDate() + 1);
|
|
363
|
+
}
|
|
364
|
+
else if (r.freq === 'WEEKLY') {
|
|
365
|
+
for (let i = 0; i < 14 && !d; i++) {
|
|
366
|
+
const day = new Date(today.getFullYear(), today.getMonth(), today.getDate() + i);
|
|
367
|
+
if (r.byWeekday?.includes(day.getDay()) && ok(at(day)))
|
|
368
|
+
d = at(day);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else if (r.freq === 'MONTHLY') {
|
|
372
|
+
for (let k = 0; k < 24 && !d; k++) {
|
|
373
|
+
const y = today.getFullYear(), m0 = today.getMonth() + k;
|
|
374
|
+
const dom = r.byWeekdayNth !== undefined
|
|
375
|
+
? nthWeekdayOfMonth(y, m0, r.byWeekday[0], r.byWeekdayNth)
|
|
376
|
+
: r.byMonthDay === -1 ? lastDayOfMonth(y, m0) : r.byMonthDay;
|
|
377
|
+
if (dom === null || dom > lastDayOfMonth(y, m0))
|
|
378
|
+
continue;
|
|
379
|
+
const x = at(new Date(y, m0, dom));
|
|
380
|
+
if (ok(x))
|
|
381
|
+
d = x;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
// YEARLY: 2월 29일은 다음 윤년까지
|
|
386
|
+
for (let k = 0; k < 9 && !d; k++) {
|
|
387
|
+
const y = today.getFullYear() + k, m0 = r.byMonth - 1;
|
|
388
|
+
if (r.byMonthDay > lastDayOfMonth(y, m0))
|
|
389
|
+
continue;
|
|
390
|
+
const x = at(new Date(y, m0, r.byMonthDay));
|
|
391
|
+
if (ok(x))
|
|
392
|
+
d = x;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!d)
|
|
396
|
+
return null;
|
|
397
|
+
const last = group[group.length - 1];
|
|
398
|
+
const result = {
|
|
399
|
+
date: d,
|
|
400
|
+
text: '',
|
|
401
|
+
index: group[0].index,
|
|
402
|
+
hasTime: hasTime || r.freq === 'HOURLY' || r.freq === 'MINUTELY',
|
|
403
|
+
recurrence: { ...r, rrule: (0, recurrence_js_1.toRRule)(r) },
|
|
404
|
+
};
|
|
405
|
+
result._end = last.index + last.text.length;
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
function partDay(y, m0, part) {
|
|
409
|
+
if (part === 'start')
|
|
410
|
+
return 1;
|
|
411
|
+
if (part === 'middle')
|
|
412
|
+
return 15;
|
|
413
|
+
return lastDayOfMonth(y, m0);
|
|
414
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type WeekStart = 'monday' | 'sunday';
|
|
2
|
+
export interface ParseOptions {
|
|
3
|
+
/** 기준 시각. 기본값: new Date() */
|
|
4
|
+
referenceDate?: Date;
|
|
5
|
+
/** 주의 시작 요일. "이번주/다음주" 계산 기준. 기본값: 'monday' */
|
|
6
|
+
weekStart?: WeekStart;
|
|
7
|
+
/** 시간이 명시되지 않았을 때 넣을 시(hour). 기본값: 0 (00:00) */
|
|
8
|
+
defaultHour?: number;
|
|
9
|
+
/**
|
|
10
|
+
* "오후 3시"를 어느 시간대의 3시로 볼지. IANA 이름 ('Asia/Seoul', 'UTC', 'America/New_York').
|
|
11
|
+
* 기본값: 실행 환경의 시간대. 서버(UTC)에서 한국 사용자 문장을 처리할 땐 'Asia/Seoul'을 넘긴다.
|
|
12
|
+
* 없는 이름이면 RangeError.
|
|
13
|
+
*/
|
|
14
|
+
timezone?: string;
|
|
15
|
+
}
|
|
16
|
+
/** resolve()가 쓰는 옵션. timezone은 parseAll()이 앞뒤에서 처리하므로 빠진다. */
|
|
17
|
+
export type ResolveOptions = Required<Omit<ParseOptions, 'timezone'>>;
|
|
18
|
+
/** 파서들이 추출하는 중간 결과. 날짜/시간 중 아는 것만 채운다. */
|
|
19
|
+
export interface Components {
|
|
20
|
+
year?: number;
|
|
21
|
+
month?: number;
|
|
22
|
+
day?: number;
|
|
23
|
+
hour?: number;
|
|
24
|
+
minute?: number;
|
|
25
|
+
/** 기준일로부터 일 단위 이동 (내일=+1) */
|
|
26
|
+
dayOffset?: number;
|
|
27
|
+
/** 기준 주로부터 주 단위 이동 (다음주=+1) */
|
|
28
|
+
weekOffset?: number;
|
|
29
|
+
/** 0=일 … 6=토 (JS getDay 규약) */
|
|
30
|
+
weekday?: number;
|
|
31
|
+
/** "주말" 단독: 오늘이 토/일이면 오늘, 아니면 다가오는 토요일 */
|
|
32
|
+
weekend?: boolean;
|
|
33
|
+
/** "지난 금요일"(오늘 제외 과거) / "다음 금요일"(오늘 제외 미래) */
|
|
34
|
+
weekdayDir?: 'past' | 'next';
|
|
35
|
+
/** "1년 뒤", "내년 이맘때": 연/월/주 이동 시 기준일의 날짜를 유지 */
|
|
36
|
+
sameDay?: boolean;
|
|
37
|
+
/** "내일 이 시간": 기준 시각의 시:분을 쓴다 */
|
|
38
|
+
sameTime?: boolean;
|
|
39
|
+
/** 음력 명절 (설날·추석·부처님 오신 날). 양력 날짜는 resolve가 util/lunarHolidays 표에서 찾는다 */
|
|
40
|
+
lunar?: 'seollal' | 'chuseok' | 'buddha';
|
|
41
|
+
/** "매주 월요일", "매달 15일": 반복 규칙 (rrule은 resolve가 만든다) */
|
|
42
|
+
recur?: Omit<Recurrence, 'rrule'>;
|
|
43
|
+
/** 연 단위 이동 (내년=+1). 지정되면 "지난 월일 → 내년" 규칙을 적용하지 않는다 */
|
|
44
|
+
yearOffset?: number;
|
|
45
|
+
/** 월 단위 이동 (다음달=+1) */
|
|
46
|
+
monthOffset?: number;
|
|
47
|
+
/** 월 안의 위치: 초(1일)/중순(15일)/말(마지막날) */
|
|
48
|
+
monthPart?: 'start' | 'middle' | 'end';
|
|
49
|
+
/** 오전/오후 등이 명시됨 (15:30 같은 24시간 표기 포함). 시 범위의 끝을 맞출 때 쓴다 */
|
|
50
|
+
meridiem?: boolean;
|
|
51
|
+
/** "자정", "24시", "밤 12시": 날짜가 있으면 그 날의 끝(다음날 00:00) */
|
|
52
|
+
midnight?: boolean;
|
|
53
|
+
/** 시/분 단위 상대 이동 */
|
|
54
|
+
hourOffset?: number;
|
|
55
|
+
minuteOffset?: number;
|
|
56
|
+
/** "3일간", "2주 동안", "3박 4일": 앞의 날짜부터 이어지는 기간. 날짜 없이 단독이면 null */
|
|
57
|
+
durationDays?: number;
|
|
58
|
+
durationMonths?: number;
|
|
59
|
+
durationMinutes?: number;
|
|
60
|
+
/** "N째 주": 1~5, 마지막은 -1. weekday가 있으면 그 달의 N번째 그 요일, 없으면 N번째 주 시작일 */
|
|
61
|
+
nthWeek?: number;
|
|
62
|
+
/** "이번 주 내내": 그 기간 전체. date는 시작, end는 기간의 끝 */
|
|
63
|
+
whole?: boolean;
|
|
64
|
+
/** 이 조각이 "~까지" 종료점인지 */
|
|
65
|
+
isEnd?: boolean;
|
|
66
|
+
/** 이 조각이 "~부터" 시작점인지 */
|
|
67
|
+
isStart?: boolean;
|
|
68
|
+
}
|
|
69
|
+
export type Frequency = 'MINUTELY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
|
70
|
+
/** 반복 일정. 필드 이름과 값은 RFC 5545 RRULE을 따른다 */
|
|
71
|
+
export interface Recurrence {
|
|
72
|
+
freq: Frequency;
|
|
73
|
+
/** 몇 번째마다 (격주 = 2) */
|
|
74
|
+
interval: number;
|
|
75
|
+
/** 요일들. 0=일 … 6=토 */
|
|
76
|
+
byWeekday?: number[];
|
|
77
|
+
/** 매달 N번째 요일 ("마지막 주 금요일" = -1). byWeekday와 함께 쓴다 */
|
|
78
|
+
byWeekdayNth?: number;
|
|
79
|
+
/** 매달 며칠 (말일 = -1) */
|
|
80
|
+
byMonthDay?: number;
|
|
81
|
+
/** 매년 몇 월 */
|
|
82
|
+
byMonth?: number;
|
|
83
|
+
/** 'FREQ=WEEKLY;BYDAY=MO,WE,FR'. date를 DTSTART로 함께 쓰면 캘린더에 그대로 넣을 수 있다 */
|
|
84
|
+
rrule: string;
|
|
85
|
+
}
|
|
86
|
+
export interface Fragment {
|
|
87
|
+
text: string;
|
|
88
|
+
index: number;
|
|
89
|
+
components: Components;
|
|
90
|
+
}
|
|
91
|
+
export interface ParseResult {
|
|
92
|
+
/** 해석된 시각 (구간이면 시작) */
|
|
93
|
+
date: Date;
|
|
94
|
+
/** 구간의 끝. "~까지", "A부터 B까지" 인 경우만 */
|
|
95
|
+
end?: Date;
|
|
96
|
+
/** 입력에서 날짜로 인식된 부분 */
|
|
97
|
+
text: string;
|
|
98
|
+
/** 입력 내 위치 */
|
|
99
|
+
index: number;
|
|
100
|
+
/** 시간이 명시되었는지 */
|
|
101
|
+
hasTime: boolean;
|
|
102
|
+
/** 반복 일정이면 그 규칙. date는 기준 시각 이후 첫 발생 */
|
|
103
|
+
recurrence?: Recurrence;
|
|
104
|
+
}
|
|
105
|
+
export type Parser = (text: string) => Fragment[];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// 음력 명절의 양력 날짜. 런타임 의존성 없이 음력 변환을 하려면 표가 필요하다.
|
|
3
|
+
//
|
|
4
|
+
// 출처: 한국천문연구원 천문우주지식정보 "월별 양음력 대조" (astro.kasi.re.kr/life/post/calendarData)
|
|
5
|
+
// 2025년은 관공서 공휴일 목록. 2026년 부처님 오신 날·추석은 공휴일 목록으로 교차 확인.
|
|
6
|
+
// KASI는 2~3년 앞까지만 공식 자료를 낸다 (2026-10 기준 2028년까지). 새 연도가 공개되면 한 줄 추가할 것.
|
|
7
|
+
// 표에 없는 연도는 추측하지 않고 null (규칙 10). 한국 설날은 한국 표준시 기준이라 중국 춘절과 하루 다를 수 있다.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.LUNAR_HOLIDAYS = void 0;
|
|
10
|
+
/** 연도 → [월, 일] (양력) */
|
|
11
|
+
exports.LUNAR_HOLIDAYS = {
|
|
12
|
+
2025: { seollal: [1, 29], buddha: [5, 5], chuseok: [10, 6] },
|
|
13
|
+
2026: { seollal: [2, 17], buddha: [5, 24], chuseok: [9, 25] },
|
|
14
|
+
2027: { seollal: [2, 7], buddha: [5, 13], chuseok: [9, 15] },
|
|
15
|
+
2028: { seollal: [1, 27], buddha: [5, 2], chuseok: [10, 3] },
|
|
16
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseKoreanNumber = parseKoreanNumber;
|
|
4
|
+
const NATIVE = {
|
|
5
|
+
한: 1, 하나: 1, 두: 2, 둘: 2, 세: 3, 셋: 3, 네: 4, 넷: 4,
|
|
6
|
+
다섯: 5, 여섯: 6, 일곱: 7, 여덟: 8, 아홉: 9, 열: 10,
|
|
7
|
+
열흘: 10, 보름: 15, 스무: 20, 스물: 20,
|
|
8
|
+
이틀: 2, 사흘: 3, 나흘: 4, 닷새: 5, 엿새: 6, 이레: 7, 여드레: 8, 아흐레: 9,
|
|
9
|
+
};
|
|
10
|
+
const SINO = {
|
|
11
|
+
일: 1, 이: 2, 삼: 3, 사: 4, 오: 5, 육: 6, 칠: 7, 팔: 8, 구: 9, 십: 10,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* "3", "세", "열", "십이", "열두" → number. 실패 시 null.
|
|
15
|
+
* v0.1: 1~99 범위만 다룬다.
|
|
16
|
+
*/
|
|
17
|
+
function parseKoreanNumber(s) {
|
|
18
|
+
if (/^\d+$/.test(s))
|
|
19
|
+
return parseInt(s, 10);
|
|
20
|
+
if (s in NATIVE)
|
|
21
|
+
return NATIVE[s];
|
|
22
|
+
// 고유어 합성: 열두, 열다섯, 스무 ...
|
|
23
|
+
for (const tens of ['스물', '스무', '열']) {
|
|
24
|
+
if (s.startsWith(tens) && s.length > tens.length) {
|
|
25
|
+
const rest = NATIVE[s.slice(tens.length)];
|
|
26
|
+
if (rest != null)
|
|
27
|
+
return NATIVE[tens] + rest;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// 한자어: 십, 십이, 이십, 이십삼
|
|
31
|
+
if (/^[일이삼사오육칠팔구십]+$/.test(s)) {
|
|
32
|
+
const parts = s.split('십');
|
|
33
|
+
if (parts.length === 1)
|
|
34
|
+
return SINO[s] ?? null;
|
|
35
|
+
const tens = parts[0] === '' ? 1 : SINO[parts[0]];
|
|
36
|
+
const ones = parts[1] === '' ? 0 : SINO[parts[1]];
|
|
37
|
+
if (tens == null || ones == null)
|
|
38
|
+
return null;
|
|
39
|
+
return tens * 10 + ones;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// IANA 시간대 변환. 런타임 의존성 없이 Intl만 쓴다.
|
|
3
|
+
//
|
|
4
|
+
// resolve.ts는 Date의 로컬 getter/setter로 계산한다. timezone 옵션이 있으면
|
|
5
|
+
// 기준 시각을 그 시간대의 벽시계 시각(로컬 필드가 같은 Date)으로 바꿔 계산하고,
|
|
6
|
+
// 결과의 벽시계 시각을 다시 실제 시점으로 되돌린다.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.toWallClock = toWallClock;
|
|
9
|
+
exports.fromWallClock = fromWallClock;
|
|
10
|
+
const formatters = new Map();
|
|
11
|
+
function formatter(timeZone) {
|
|
12
|
+
let f = formatters.get(timeZone);
|
|
13
|
+
if (!f) {
|
|
14
|
+
// 없는 시간대면 여기서 RangeError
|
|
15
|
+
f = new Intl.DateTimeFormat('en-US', {
|
|
16
|
+
timeZone,
|
|
17
|
+
hourCycle: 'h23',
|
|
18
|
+
year: 'numeric',
|
|
19
|
+
month: 'numeric',
|
|
20
|
+
day: 'numeric',
|
|
21
|
+
hour: 'numeric',
|
|
22
|
+
minute: 'numeric',
|
|
23
|
+
second: 'numeric',
|
|
24
|
+
});
|
|
25
|
+
formatters.set(timeZone, f);
|
|
26
|
+
}
|
|
27
|
+
return f;
|
|
28
|
+
}
|
|
29
|
+
/** 시점 t에서 그 시간대의 벽시계 필드 */
|
|
30
|
+
function fieldsAt(t, timeZone) {
|
|
31
|
+
const parts = {};
|
|
32
|
+
for (const p of formatter(timeZone).formatToParts(new Date(t))) {
|
|
33
|
+
if (p.type !== 'literal')
|
|
34
|
+
parts[p.type] = +p.value;
|
|
35
|
+
}
|
|
36
|
+
return [parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second];
|
|
37
|
+
}
|
|
38
|
+
/** 시점 t에서 그 시간대의 UTC 오프셋(ms). 서울 = +9h */
|
|
39
|
+
function offsetAt(t, timeZone) {
|
|
40
|
+
const [y, m0, d, h, mi, s] = fieldsAt(t, timeZone);
|
|
41
|
+
return Date.UTC(y, m0, d, h, mi, s) - (t - (t % 1000));
|
|
42
|
+
}
|
|
43
|
+
/** 실제 시점 → 그 시간대의 벽시계 시각을 로컬 필드로 갖는 Date */
|
|
44
|
+
function toWallClock(instant, timeZone) {
|
|
45
|
+
const [y, m0, d, h, mi, s] = fieldsAt(instant.getTime(), timeZone);
|
|
46
|
+
return new Date(y, m0, d, h, mi, s, instant.getMilliseconds());
|
|
47
|
+
}
|
|
48
|
+
/** 로컬 필드를 그 시간대의 벽시계 시각으로 보고 → 실제 시점 */
|
|
49
|
+
function fromWallClock(wall, timeZone) {
|
|
50
|
+
const asUtc = Date.UTC(wall.getFullYear(), wall.getMonth(), wall.getDate(), wall.getHours(), wall.getMinutes(), wall.getSeconds(), wall.getMilliseconds());
|
|
51
|
+
// 오프셋은 시점에 따라 달라지므로(서머타임) 한 번 더 맞춘다
|
|
52
|
+
const first = offsetAt(asUtc, timeZone);
|
|
53
|
+
const second = offsetAt(asUtc - first, timeZone);
|
|
54
|
+
return new Date(asUtc - second);
|
|
55
|
+
}
|
package/dist/event.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ParseResult, Recurrence } from './types.js';
|
|
2
|
+
/** parseEvent()의 결과: 할 일·일정 한 줄을 제목과 시점으로 나눈 것 */
|
|
3
|
+
export interface ParsedEvent {
|
|
4
|
+
/** 날짜 부분을 뺀 나머지 ("내일 3시 치과" → "치과"). 없으면 '' */
|
|
5
|
+
title: string;
|
|
6
|
+
/** 첫 번째 날짜 표현의 date. 날짜가 없으면 null */
|
|
7
|
+
date: Date | null;
|
|
8
|
+
/** 구간의 끝이나 마감 */
|
|
9
|
+
end?: Date;
|
|
10
|
+
hasTime: boolean;
|
|
11
|
+
/** 반복 일정이면 그 규칙 */
|
|
12
|
+
recurrence?: Recurrence;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
|
|
16
|
+
* 장소("강남역에서")나 동사("만나기")는 그대로 남긴다.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toEvent(text: string, results: ParseResult[]): ParsedEvent;
|