ko-chrono 0.3.1 → 0.4.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/dist/index.js CHANGED
@@ -6,17 +6,25 @@ import { relativeMonth } from './parsers/relativeMonth.js';
6
6
  import { relativeYear } from './parsers/relativeYear.js';
7
7
  import { time } from './parsers/time.js';
8
8
  import { duration } from './parsers/duration.js';
9
- import { recurrence } from './parsers/recurrence.js';
9
+ import { recurrence, toRRule } from './parsers/recurrence.js';
10
10
  import { holiday } from './parsers/holiday.js';
11
11
  import { alignEnd, groupFragments, isDayOnly, isTimeOnly, isWeekdayOnly, merge, resolve } from './resolve.js';
12
12
  import { fromWallClock, toWallClock } from './util/timezone.js';
13
+ import { parseKoreanNumber } from './util/number.js';
13
14
  import { toEvent } from './event.js';
15
+ export { toICS } from './ics.js';
14
16
  const PARSERS = [relativeDay, offset, weekday, absoluteDate, relativeMonth, relativeYear, time, duration, recurrence, holiday];
15
- // "~까지" / "~부터" 접미 감지. "3시~5시"의 "~"도 시작 표시로 본다
16
- const UNTIL = /^\s*(전까지|까지|이내|안에|안으로|내로|중으로)/;
17
+ // "~까지" / "~부터" 접미 감지. "3시~5시"의 "~"도 시작 표시로 본다. "다음주 중", "오늘 중에"도 마감
18
+ const UNTIL = /^\s*(전까지|까지|이내|안에|안으로|내로|중으로|중에(?![가-힣])|중(?![가-힣]))/;
17
19
  const FROM = /^\s*(부터|~)\s*/;
18
20
  // "이번 주 내내" → 그 기간 전체
19
21
  const WHOLE = /^\s*내내/;
22
+ // 반복 일정과 그 앞뒤 날짜 사이 (규칙 34): "다음달까지 매주 월요일", "매주 월요일, 12월 20일까지"
23
+ const GAP = /^[\s,]*$/;
24
+ // "다음주부터 매주 월요일"
25
+ const FROM_GAP = /^\s*부터[\s,]*$/;
26
+ // "총 8회", "10번 반복", "총 열 번". "매일 3번"은 하루 횟수일 수 있어 "총"이나 "반복"이 있을 때만
27
+ const COUNT = /^[\s,]*(?:총\s*(\d{1,3}|[한두세네다섯여섯일곱여덟아홉열스무]+)\s*(?:번|회)(?:\s*반복)?|(\d{1,3}|[한두세네다섯여섯일곱여덟아홉열스무]+)\s*(?:번|회)\s*반복)/;
20
28
  function withDefaults(opts) {
21
29
  return {
22
30
  referenceDate: opts?.referenceDate ?? new Date(),
@@ -58,6 +66,9 @@ export function parseAll(text, options) {
58
66
  const out = { ...r, date: fromWallClock(r.date, timezone) };
59
67
  if (r.end)
60
68
  out.end = fromWallClock(r.end, timezone);
69
+ // rrule의 UNTIL은 벽시계 그대로 두고, Date 값만 실제 시점으로
70
+ if (r.recurrence?.until)
71
+ out.recurrence = { ...r.recurrence, until: fromWallClock(r.recurrence.until, timezone) };
61
72
  if (r.ambiguity) {
62
73
  out.ambiguity = { ...r.ambiguity, alternatives: r.ambiguity.alternatives.map((a) => fromWallClock(a, timezone)) };
63
74
  }
@@ -70,6 +81,7 @@ function parseLocal(text, opts) {
70
81
  const groups = groupFragments(frags, text);
71
82
  const results = [];
72
83
  const comps = [];
84
+ const resultGroups = [];
73
85
  for (const g of groups) {
74
86
  const last = g[g.length - 1];
75
87
  const tail = text.slice(last.index + last.text.length);
@@ -93,10 +105,15 @@ function parseLocal(text, opts) {
93
105
  r.text = raw.trim();
94
106
  results.push(r);
95
107
  comps.push(merge(g.map((f) => f.components)));
108
+ resultGroups.push(g);
96
109
  }
97
110
  // "A부터 B까지", "A~B" → 하나의 구간으로 합침
98
111
  const merged = [];
112
+ const mergedGroups = [];
113
+ const mergedComps = [];
99
114
  for (let i = 0; i < results.length; i++) {
115
+ mergedGroups.push(resultGroups[i]);
116
+ mergedComps.push(comps[i]);
100
117
  const a = results[i], b = results[i + 1];
101
118
  const from = FROM.exec(text.slice(a.index + a.text.length));
102
119
  // "~"는 바로 뒤에 B가 붙어 있을 때만. "부터"는 B가 "까지"로 끝나거나,
@@ -137,7 +154,78 @@ function parseLocal(text, opts) {
137
154
  merged.push(a);
138
155
  }
139
156
  }
140
- return merged;
157
+ return bindRecurrence(text, merged, mergedGroups, mergedComps, opts);
158
+ }
159
+ /**
160
+ * 반복 일정 앞뒤의 날짜를 시작·끝으로 묶는다 (규칙 34).
161
+ * - 앞이나 뒤의 마감·구간 ("다음달까지", "11월 1일부터 12월 31일까지") → 그 시작 이후 첫 발생, 끝은 UNTIL
162
+ * - 앞의 "A부터" → A 이후 첫 발생
163
+ * - 뒤의 "총 N회", "N번 반복" → COUNT
164
+ * 끝이 첫 발생보다 앞이면 묶지 않는다 ("어제까지 매주 월요일").
165
+ */
166
+ function bindRecurrence(text, results, groups, comps, opts) {
167
+ const out = [];
168
+ for (let i = 0; i < results.length; i++) {
169
+ let r = results[i];
170
+ if (!r.recurrence) {
171
+ out.push(r);
172
+ continue;
173
+ }
174
+ let from = r.index;
175
+ let to = r.index + r.text.length;
176
+ let bound = false;
177
+ // 시작·끝이 되는 날짜를 적용해 첫 발생을 다시 계산한다
178
+ // 시각만 있는 마감("5시까지 매일")은 그날 하루의 끝이지 반복의 끝이 아니다
179
+ const bindTo = (other, j) => {
180
+ if (isTimeOnly(comps[j]))
181
+ return false;
182
+ // 마감("다음달까지")의 date는 기준 시각이라 그대로, 지난 시작("지난주부터")은 기준 시각부터
183
+ const begin = other.date > opts.referenceDate ? other.date : opts.referenceDate;
184
+ const again = resolve(groups[i], { ...opts, referenceDate: begin });
185
+ if (!again?.recurrence)
186
+ return false;
187
+ // "N주 동안"은 새 시작부터 다시 센 값(again)을 쓴다
188
+ const until = again.recurrence.until ?? other.end;
189
+ if (until && until < again.date)
190
+ return false;
191
+ const shift = again.date.getTime() - r.date.getTime();
192
+ r = { ...r, date: again.date, recurrence: { ...again.recurrence, until } };
193
+ if (r.end)
194
+ r.end = new Date(r.end.getTime() + shift);
195
+ return true;
196
+ };
197
+ const prev = out[out.length - 1];
198
+ const prevIndex = results.indexOf(prev);
199
+ if (prev && !prev.recurrence) {
200
+ const gap = text.slice(prev.index + prev.text.length, r.index);
201
+ if ((prev.end ? GAP : FROM_GAP).test(gap) && bindTo(prev, prevIndex)) {
202
+ from = prev.index;
203
+ out.pop();
204
+ bound = true;
205
+ }
206
+ }
207
+ const next = results[i + 1];
208
+ if (!r.recurrence.until && next?.end && !next.recurrence && GAP.test(text.slice(to, next.index)) && bindTo(next, i + 1)) {
209
+ to = next.index + next.text.length;
210
+ bound = true;
211
+ i++;
212
+ }
213
+ const count = COUNT.exec(text.slice(to));
214
+ const n = count ? parseKoreanNumber(count[1] ?? count[2]) : null;
215
+ if (n && !r.recurrence.until) {
216
+ r = { ...r, recurrence: { ...r.recurrence, count: n } };
217
+ to += count[0].length;
218
+ bound = true;
219
+ }
220
+ if (bound) {
221
+ const rule = r.recurrence;
222
+ if (rule.until === undefined)
223
+ delete rule.until;
224
+ r = { ...r, index: from, text: text.slice(from, to).trim(), recurrence: { ...rule, rrule: toRRule(rule, !r.hasTime) } };
225
+ }
226
+ out.push(r);
227
+ }
228
+ return out;
141
229
  }
142
230
  /**
143
231
  * 문장에서 첫 번째 날짜/시간 표현을 찾는다. 없으면 null.
@@ -11,8 +11,8 @@ const HANGUL_MONTH = { 유: 6, 시: 10 };
11
11
  const YEAR_ONLY = /(?<!\d)((?:19|20)\d{2})\s*년(?!\s*\d{1,2}\s*월)/g;
12
12
  // "11월" 단독, "5일" 단독
13
13
  const MONTH_ONLY = /(?<![\d가-힣])(\d{1,2})\s*월(?!\s*\d{1,2}\s*일|생|세)/g;
14
- // "5일간", "3일 동안", "3박 4일"은 기간이지 날짜가 아니다
15
- const DAY_ONLY = /(?<!\d)(?<!\d\s*박\s*)(\d{1,2})\s*일(?!요일|간|장|째|차|분|\s*동안|\s*연속)/g;
14
+ // "5일간", "3일 동안", "3박 4일"은 기간이지 날짜가 아니다. "주 5일 근무"(횟수), "30일 챌린지"도 아니다
15
+ const DAY_ONLY = /(?<!\d)(?<!\d\s*박\s*)(?<!(?<![가-힣])주\s*)(\d{1,2})\s*일(?!요일|간|장|째|차|분|\s*동안|\s*연속|\s*챌린지)/g;
16
16
  /** 두 자리 연도 확장: 00~69 → 20xx, 70~99 → 19xx */
17
17
  const expandYear = (y) => {
18
18
  const n = +y;
@@ -4,6 +4,7 @@ const FIXED = {
4
4
  크리스마스: [12, 25],
5
5
  성탄절: [12, 25],
6
6
  신정: [1, 1],
7
+ '새해 첫날': [1, 1],
7
8
  삼일절: [3, 1],
8
9
  '근로자의 날': [5, 1],
9
10
  노동절: [5, 1],
@@ -36,7 +36,9 @@ export const offset = (text) => {
36
36
  break;
37
37
  case '달':
38
38
  case '개월':
39
+ // "한 달 뒤" = 다음 달 같은 날 (그 달 1일이 아니라)
39
40
  c.monthOffset = sign * n;
41
+ c.sameDay = true;
40
42
  break;
41
43
  case '년':
42
44
  // "2028년 후"는 "2028년 뒤"가 아니라 "2028년 이후"일 가능성이 크다. 100년 넘게는 상대 이동으로 보지 않는다
@@ -1,5 +1,9 @@
1
1
  import type { Parser, Recurrence } from '../types.js';
2
2
  /** 반복 일정. 날짜는 계산하지 않고 규칙만 뽑는다 (첫 발생일은 resolve가 계산) */
3
3
  export declare const recurrence: Parser;
4
- /** RFC 5545 RRULE 문자열. 요일은 월요일부터 */
5
- export declare function toRRule(r: Omit<Recurrence, 'rrule'>): string;
4
+ /**
5
+ * RFC 5545 RRULE 문자열. 요일은 월요일부터.
6
+ * UNTIL은 벽시계 시각 그대로 쓴다 (DTSTART가 TZID 또는 floating time일 때의 형식).
7
+ * dateOnly면 DTSTART가 DATE이므로 UNTIL도 날짜만.
8
+ */
9
+ export declare function toRRule(r: Omit<Recurrence, 'rrule'>, dateOnly?: boolean): string;
@@ -95,8 +95,13 @@ export const recurrence = (text) => {
95
95
  return out;
96
96
  };
97
97
  const CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
98
- /** RFC 5545 RRULE 문자열. 요일은 월요일부터 */
99
- export function toRRule(r) {
98
+ const pad = (n) => String(n).padStart(2, '0');
99
+ /**
100
+ * RFC 5545 RRULE 문자열. 요일은 월요일부터.
101
+ * UNTIL은 벽시계 시각 그대로 쓴다 (DTSTART가 TZID 또는 floating time일 때의 형식).
102
+ * dateOnly면 DTSTART가 DATE이므로 UNTIL도 날짜만.
103
+ */
104
+ export function toRRule(r, dateOnly = false) {
100
105
  const parts = [`FREQ=${r.freq}`];
101
106
  if (r.interval > 1)
102
107
  parts.push(`INTERVAL=${r.interval}`);
@@ -108,5 +113,13 @@ export function toRRule(r) {
108
113
  const days = [...r.byWeekday].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7)).map((d) => CODE[d]);
109
114
  parts.push(`BYDAY=${days.map((c) => (r.byWeekdayNth ? `${r.byWeekdayNth}${c}` : c)).join(',')}`);
110
115
  }
116
+ if (r.until) {
117
+ const u = r.until;
118
+ const date = `${u.getFullYear()}${pad(u.getMonth() + 1)}${pad(u.getDate())}`;
119
+ parts.push(`UNTIL=${dateOnly ? date : `${date}T${pad(u.getHours())}${pad(u.getMinutes())}${pad(u.getSeconds())}`}`);
120
+ }
121
+ else if (r.count) {
122
+ parts.push(`COUNT=${r.count}`);
123
+ }
111
124
  return parts.join(';');
112
125
  }
@@ -1,4 +1,6 @@
1
1
  import { parseKoreanNumber } from '../util/number.js';
2
+ // "24시 편의점", "24시 영업": 하루 종일 연다는 뜻이지 시각이 아니다
3
+ const OPEN_ALL_DAY = /^\s*(?:편의점|영업|운영|무인|사우나|찜질방|약국|병원|동물병원|매장|카페|식당|해장국|김밥|헬스|콜센터|상담)/;
2
4
  // "오후 3시", "3시 반", "오전 10시 30분", "새벽 2시", "저녁 7시", "낮 12시", "15시", "7시30", "3시 정각"
3
5
  const CLOCK = /(오전|오후|새벽|아침|저녁|밤|낮|정오)?\s*(\d{1,2}|한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열두)\s*시(?!간)\s*(?:(반)|(\d{1,2})\s*분|(\d{2})(?![\d가-힣])|정각)?/g;
4
6
  // "5시 10분 전" → 4:50
@@ -97,6 +99,8 @@ export const time = (text) => {
97
99
  const raw = parseKoreanNumber(m[2]);
98
100
  if (raw == null || raw > 24)
99
101
  continue;
102
+ if (raw === 24 && !m[1] && OPEN_ALL_DAY.test(text.slice(m.index + m[0].length)))
103
+ continue;
100
104
  if (!take(m.index, m[0].length))
101
105
  continue;
102
106
  const hour = applyMeridiem(m[1], raw);
@@ -5,6 +5,10 @@ const WEEK_WORDS = {
5
5
  };
6
6
  const WEEK_WORD = '지지난|저저번|지난|저번|이번|다다음|다음|다담|담';
7
7
  const DAY = '월|화|수|목|금|토|일';
8
+ // 요일 글자만 쓸 때(규칙 36) 뒤에 와야 하는 것: 끝, 구두점, 시각("월 2시", "금 저녁"), 다른 요일 글자("수, 목"),
9
+ // 조사("금에", "월까지"). "다음주 일정", "다음주 일 많아", "이번주 목 아파"의 일·목은 요일이 아님
10
+ const DAY_ALONE = `(?=\\s*$|\\s*[,.!?~~)]|\\s*(?:\\d|오전|오후|새벽|아침|점심|낮(?!잠)|저녁|밤(?!새)|(?:${DAY})(?![가-힣]))` +
11
+ `|(?:에|엔|은|는|도|나|이나|쯤)(?![가-힣])|에는|까지|부터)`;
8
12
  // "이번 주말", "다음주말", "지난 주말" → 그 주의 토요일
9
13
  const WEEKEND = new RegExp(`(${WEEK_WORD})\\s*주말`, 'g');
10
14
  // "월~금", "화~목요일" → 두 조각 (index.ts가 "~"로 구간을 합친다)
@@ -12,8 +16,8 @@ const RANGE = new RegExp(`(?<![\\d가-힣])(${DAY})\\s*~\\s*((${DAY})(?:요일|
12
16
  // "11/3(화)", "11월 5일 (목)" 뒤에 붙는 요일 표시
13
17
  const PAREN = new RegExp(`\\((${DAY})\\)`, 'g');
14
18
  // "11월 첫째 주 월요일", "둘째 주 수요일", "마지막 주 금요일", "12월 셋째 주"
15
- // 요일 글자만 쓸 때("첫째 주 월")는 뒤에 한글이 없어야 한다 ("둘째 주 일정"의 "일"은 요일이 아님)
16
- const NTH = new RegExp(`(?:(\\d{1,2})\\s*월\\s*)?(첫째|첫|둘째|셋째|넷째|다섯째|마지막)\\s*주(?:\\s*(${DAY})(?:요일|욜|(?![가-힣])))?`, 'g');
19
+ // 요일 글자만 쓸 때("첫째 주 월")는 DAY_ALONE을 따른다 ("둘째 주 일정"의 "일"은 요일이 아님)
20
+ const NTH = new RegExp(`(?:(\\d{1,2})\\s*월\\s*)?(첫째|첫|둘째|셋째|넷째|다섯째|마지막)\\s*주(?:\\s*(${DAY})(?:요일|욜|${DAY_ALONE}))?`, 'g');
17
21
  const NTH_NUM = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 다섯째: 5, 마지막: -1 };
18
22
  // "2주 뒤 월요일", "3주 후 금요일" → N주 뒤의 그 주의 X요일 ("다다음주 월요일"과 같은 계산)
19
23
  const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:뒤|후)\\s*(${DAY})(?:요일|욜)`, 'g');
@@ -22,18 +26,18 @@ const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:
22
26
  // "전주"는 도시 이름과 너무 자주 겹쳐서 요일이 붙을 때만
23
27
  const FORMAL_WEEK = { 전전: -2, 전: -1, 금: 0, 차: 1, 익: 1, 차차: 2 };
24
28
  const FORMAL_WITH_DAY = new RegExp(`(?<![가-힣])(전전|차차|전|금|차|익)주\\s*(${DAY})(?:요일|욜)`, 'g');
25
- // "차주 중으로", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
26
- const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)|에는|중으로|중에|내로|내|안에|안으로|까지|초|말|부터))|(금)주(?=\s*(?:중으로|중에|내로|내|안에|안으로|까지|초|말|부터)))/g;
29
+ // "차주 중으로", "차주 중", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
30
+ const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)|에는|중으로|중에|중(?![가-힣])|내로|내|안에|안으로|까지|초|말|부터))|(금)주(?=\s*(?:중으로|중에|중(?![가-힣])|내로|내|안에|안으로|까지|초|말|부터)))/g;
27
31
  // "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
28
32
  const DIR = new RegExp(`(지난|저번|다음)\\s*(${DAY})(?:요일|욜)`, 'g');
29
33
  // "주말에 보자" ("이번 주말"은 위 WEEKEND가 먼저 잡는다)
30
34
  const BARE_WEEKEND = /(?<![가-힣])주말(?=[\s,에엔은는도까부]|$)/g;
31
- // "다음주 화요일", "이번 주 금", "다다음주 월요일", "담주 월욜"
32
- const WITH_WEEK = new RegExp(`(${WEEK_WORD})\\s*주\\s*(${DAY})(요일|욜)?`, 'g');
35
+ // "다음주 화요일", "이번 주 금", "다다음주 월요일", "담주 월욜", "담주 월 2시"
36
+ const WITH_WEEK = new RegExp(`(${WEEK_WORD})\\s*주\\s*(${DAY})(?:요일|욜|${DAY_ALONE})`, 'g');
33
37
  // "화요일", "화욜" 단독 (요일 글자만은 오탐이 많아 '요일'/'욜'까지 요구)
34
38
  const BARE = /(?<![가-힣])(월|화|수|목|금|토|일)(?:요일|욜)/g;
35
39
  // "다음주", "이번주" 단독 (요일 없음) → 그 주의 시작 요일
36
- const WEEK_ONLY = new RegExp(`(${WEEK_WORD})\\s*주(\\s*이맘때)?(?!\\s*(${DAY}))`, 'g');
40
+ const WEEK_ONLY = new RegExp(`(${WEEK_WORD})\\s*주(\\s*이맘때)?(?!\\s*(?:${DAY})(?:요일|욜|${DAY_ALONE}))`, 'g');
37
41
  /** 요일 관련 표현 */
38
42
  export const weekday = (text) => {
39
43
  const out = [];
@@ -85,7 +89,7 @@ export const weekday = (text) => {
85
89
  taken.push([m.index, m.index + m[0].length]);
86
90
  }
87
91
  for (const m of text.matchAll(WEEKEND)) {
88
- out.push({ text: m[0], index: m.index, components: { weekOffset: WEEK_WORDS[m[1]], weekday: 6 } });
92
+ out.push({ text: m[0], index: m.index, components: { weekOffset: WEEK_WORDS[m[1]], weekday: 6, weekend: true } });
89
93
  taken.push([m.index, m.index + m[0].length]);
90
94
  }
91
95
  for (const m of text.matchAll(BARE_WEEKEND)) {
package/dist/resolve.js CHANGED
@@ -67,6 +67,7 @@ const DATE_KEYS = [
67
67
  /** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
68
68
  export function isTimeOnly(c) {
69
69
  return (c.hour !== undefined &&
70
+ c.recur === undefined &&
70
71
  c.hourOffset === undefined &&
71
72
  c.minuteOffset === undefined &&
72
73
  !DATE_KEYS.some((k) => c[k] !== undefined));
@@ -110,7 +111,9 @@ function hasConflict(a, b) {
110
111
  const weekdayNote = (x, y) => x.day !== undefined && y.weekday !== undefined && !y.weekend &&
111
112
  y.weekOffset === undefined && y.day === undefined && y.dayOffset === undefined && y.monthPart === undefined;
112
113
  const dayConflict = both(dayKeys) && !weekdayNote(a, b) && !weekdayNote(b, a);
113
- return dayConflict || both(timeKeys) || dateVsRel;
114
+ // 규칙 34: "다음주부터 매주 월요일"의 날짜는 반복의 시작·끝이라 따로 해석한 뒤 parseAll이 묶는다
115
+ const recurVsDate = (a.recur !== undefined && has(b, dateKeys)) || (b.recur !== undefined && has(a, dateKeys));
116
+ return dayConflict || both(timeKeys) || dateVsRel || recurVsDate;
114
117
  }
115
118
  export function resolve(group, opts) {
116
119
  const c = merge(group.map((f) => f.components));
@@ -159,7 +162,15 @@ export function resolve(group, opts) {
159
162
  if (!isLunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
160
163
  let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
161
164
  // "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
162
- const m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
165
+ let m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
166
+ // "5일까지"처럼 일만 있으면: 오늘이 그날을 넘었으면 다음 달, 아니면 이번 달 (오늘 포함).
167
+ // 그 달에 없는 날("11월에 31일")은 아래에서 null
168
+ if (!hasYear && c.month === undefined && c.day !== undefined && c.monthOffset === undefined && c.nthWeek === undefined) {
169
+ if (c.day < ref.getDate())
170
+ m0 += 1;
171
+ y += Math.floor(m0 / 12);
172
+ m0 %= 12;
173
+ }
163
174
  // 연도 미지정 + 이미 지났으면 내년으로.
164
175
  // 월일("3월 1일")은 그 날이, 월("3월", "2월 말")은 그 달이 통째로 지났을 때.
165
176
  if (!hasYear && c.month !== undefined) {
@@ -313,6 +324,9 @@ export function resolve(group, opts) {
313
324
  // "~까지", "~이내": 시작은 기준 시각, 끝은 해당 시점(시간 없으면 그날 23:59)
314
325
  // "~내내": 시작은 그 기간의 시작, 끝은 기간의 끝
315
326
  result.end = hasTime ? new Date(d) : periodEnd(d, c);
327
+ // "이번 주말 내내" → 토요일부터 일요일까지
328
+ if (c.whole && c.weekend && !hasTime && result.end.getDay() === 6)
329
+ result.end.setDate(result.end.getDate() + 1);
316
330
  // 규칙 30: "어제까지"처럼 이미 지난 마감은 구간이 아니라 그 시점 (date = end)
317
331
  if (c.isEnd)
318
332
  result.date = result.end < ref ? new Date(result.end) : new Date(ref);
@@ -324,7 +338,7 @@ function periodEnd(d, c) {
324
338
  let end = new Date(d);
325
339
  if (c.weekOffset !== undefined && c.weekday === undefined)
326
340
  end.setDate(end.getDate() + 6);
327
- else if (c.monthOffset !== undefined && c.monthPart === undefined && c.day === undefined)
341
+ else if (c.monthOffset !== undefined && c.monthPart === undefined && c.day === undefined && !c.sameDay)
328
342
  end = new Date(end.getFullYear(), end.getMonth(), lastDayOfMonth(end.getFullYear(), end.getMonth()));
329
343
  else if ((c.year !== undefined || c.yearOffset !== undefined) && c.month === undefined && !c.sameDay)
330
344
  end = new Date(end.getFullYear(), 11, 31);
@@ -392,13 +406,25 @@ function resolveRecurrence(group, c, opts) {
392
406
  }
393
407
  if (!d)
394
408
  return null;
409
+ // 규칙 34: "10주 동안 매주 월요일" → 오늘(또는 "A부터"의 A)부터 그 기간의 마지막 날 23:59까지
410
+ const rule = { ...r };
411
+ if (c.durationDays !== undefined || c.durationMonths !== undefined) {
412
+ // N달 뒤 같은 날의 전날까지. 그 달에 없는 날이면 말일로 맞춘다 (11/30 + 3달 → 2/28의 전날)
413
+ const until = sameDayIn(today.getFullYear(), today.getMonth() + (c.durationMonths ?? 0), today.getDate());
414
+ until.setDate(until.getDate() + (c.durationDays ?? 0) - 1);
415
+ until.setHours(23, 59, 0, 0);
416
+ if (until < d)
417
+ return null;
418
+ rule.until = until;
419
+ }
395
420
  const last = group[group.length - 1];
421
+ const timed = hasTime || r.freq === 'HOURLY' || r.freq === 'MINUTELY';
396
422
  const result = {
397
423
  date: d,
398
424
  text: '',
399
425
  index: group[0].index,
400
- hasTime: hasTime || r.freq === 'HOURLY' || r.freq === 'MINUTELY',
401
- recurrence: { ...r, rrule: toRRule(r) },
426
+ hasTime: timed,
427
+ recurrence: { ...rule, rrule: toRRule(rule, !timed) },
402
428
  };
403
429
  result._end = last.index + last.text.length;
404
430
  return result;
package/dist/types.d.ts CHANGED
@@ -88,7 +88,15 @@ export interface Recurrence {
88
88
  byMonthDay?: number;
89
89
  /** 매년 몇 월 */
90
90
  byMonth?: number;
91
- /** 'FREQ=WEEKLY;BYDAY=MO,WE,FR'. date를 DTSTART로 함께 쓰면 캘린더에 그대로 넣을 수 있다 */
91
+ /** 마지막 발생 한도 ("다음달까지 매주 월요일" → 다음달 말일 23:59). count와 함께 오지 않는다 */
92
+ until?: Date;
93
+ /** 전체 횟수 ("총 8회", "10번 반복") */
94
+ count?: number;
95
+ /**
96
+ * 'FREQ=WEEKLY;BYDAY=MO,WE,FR'. date를 DTSTART로 함께 쓰면 캘린더에 그대로 넣을 수 있다.
97
+ * UNTIL은 date와 같은 벽시계 시각(floating time)으로 쓴다. 시각이 없으면 'UNTIL=20261130'처럼 날짜만.
98
+ * DTSTART를 UTC나 TZID로 쓰는 캘린더 파일에는 toICS()가 UNTIL을 UTC로 바꿔 넣는다
99
+ */
92
100
  rrule: string;
93
101
  }
94
102
  export interface Fragment {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ko-chrono",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "한국어 자연어 날짜/시간 파서 — '다음주 화요일 오후 3시' → Date",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -16,10 +16,15 @@
16
16
  "types": "./dist/cjs/index.d.ts",
17
17
  "default": "./dist/cjs/index.js"
18
18
  }
19
- }
19
+ },
20
+ "./package.json": "./package.json"
20
21
  },
21
22
  "sideEffects": false,
22
- "files": ["dist", "README.md", "LICENSE"],
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
23
28
  "scripts": {
24
29
  "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs",
25
30
  "prepublishOnly": "npm run build && npm test",
@@ -28,7 +33,16 @@
28
33
  "typecheck": "tsc --noEmit",
29
34
  "playground": "npm run build && npx serve ."
30
35
  },
31
- "keywords": ["korean", "한국어", "date", "parser", "natural-language", "날짜", "nlp", "chrono"],
36
+ "keywords": [
37
+ "korean",
38
+ "한국어",
39
+ "date",
40
+ "parser",
41
+ "natural-language",
42
+ "날짜",
43
+ "nlp",
44
+ "chrono"
45
+ ],
32
46
  "license": "MIT",
33
47
  "repository": {
34
48
  "type": "git",