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