ko-chrono 0.0.0-stage → 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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -2
  3. package/dist/cjs/event.d.ts +18 -0
  4. package/dist/cjs/event.js +31 -0
  5. package/dist/cjs/index.d.ts +55 -0
  6. package/dist/cjs/index.js +189 -0
  7. package/dist/cjs/package.json +1 -0
  8. package/dist/cjs/parsers/absoluteDate.d.ts +3 -0
  9. package/dist/cjs/parsers/absoluteDate.js +95 -0
  10. package/dist/cjs/parsers/duration.d.ts +3 -0
  11. package/dist/cjs/parsers/duration.js +45 -0
  12. package/dist/cjs/parsers/holiday.d.ts +3 -0
  13. package/dist/cjs/parsers/holiday.js +64 -0
  14. package/dist/cjs/parsers/offset.d.ts +3 -0
  15. package/dist/cjs/parsers/offset.js +64 -0
  16. package/dist/cjs/parsers/recurrence.d.ts +5 -0
  17. package/dist/cjs/parsers/recurrence.js +117 -0
  18. package/dist/cjs/parsers/relativeDay.d.ts +3 -0
  19. package/dist/cjs/parsers/relativeDay.js +26 -0
  20. package/dist/cjs/parsers/relativeMonth.d.ts +3 -0
  21. package/dist/cjs/parsers/relativeMonth.js +30 -0
  22. package/dist/cjs/parsers/relativeYear.d.ts +3 -0
  23. package/dist/cjs/parsers/relativeYear.js +30 -0
  24. package/dist/cjs/parsers/time.d.ts +3 -0
  25. package/dist/cjs/parsers/time.js +149 -0
  26. package/dist/cjs/parsers/weekday.d.ts +3 -0
  27. package/dist/cjs/parsers/weekday.js +109 -0
  28. package/dist/cjs/resolve.d.ts +21 -0
  29. package/dist/cjs/resolve.js +414 -0
  30. package/dist/cjs/types.d.ts +105 -0
  31. package/dist/cjs/types.js +2 -0
  32. package/dist/cjs/util/lunarHolidays.d.ts +3 -0
  33. package/dist/cjs/util/lunarHolidays.js +16 -0
  34. package/dist/cjs/util/number.d.ts +5 -0
  35. package/dist/cjs/util/number.js +42 -0
  36. package/dist/cjs/util/timezone.d.ts +4 -0
  37. package/dist/cjs/util/timezone.js +55 -0
  38. package/dist/event.d.ts +18 -0
  39. package/dist/event.js +28 -0
  40. package/dist/index.d.ts +55 -0
  41. package/dist/index.js +183 -0
  42. package/dist/parsers/absoluteDate.d.ts +3 -0
  43. package/dist/parsers/absoluteDate.js +91 -0
  44. package/dist/parsers/duration.d.ts +3 -0
  45. package/dist/parsers/duration.js +41 -0
  46. package/dist/parsers/holiday.d.ts +3 -0
  47. package/dist/parsers/holiday.js +60 -0
  48. package/dist/parsers/offset.d.ts +3 -0
  49. package/dist/parsers/offset.js +60 -0
  50. package/dist/parsers/recurrence.d.ts +5 -0
  51. package/dist/parsers/recurrence.js +112 -0
  52. package/dist/parsers/relativeDay.d.ts +3 -0
  53. package/dist/parsers/relativeDay.js +22 -0
  54. package/dist/parsers/relativeMonth.d.ts +3 -0
  55. package/dist/parsers/relativeMonth.js +26 -0
  56. package/dist/parsers/relativeYear.d.ts +3 -0
  57. package/dist/parsers/relativeYear.js +26 -0
  58. package/dist/parsers/time.d.ts +3 -0
  59. package/dist/parsers/time.js +145 -0
  60. package/dist/parsers/weekday.d.ts +3 -0
  61. package/dist/parsers/weekday.js +105 -0
  62. package/dist/resolve.d.ts +21 -0
  63. package/dist/resolve.js +405 -0
  64. package/dist/types.d.ts +105 -0
  65. package/dist/types.js +1 -0
  66. package/dist/util/lunarHolidays.d.ts +3 -0
  67. package/dist/util/lunarHolidays.js +13 -0
  68. package/dist/util/number.d.ts +5 -0
  69. package/dist/util/number.js +39 -0
  70. package/dist/util/timezone.d.ts +4 -0
  71. package/dist/util/timezone.js +51 -0
  72. package/package.json +40 -4
package/dist/event.js ADDED
@@ -0,0 +1,28 @@
1
+ // 날짜 바로 뒤에 붙은 조사: "3시에", "모레까지는", "주말엔"
2
+ const TRAILING_PARTICLE = /^(?:에는|에도|에|엔|은|는|도|까지는|까지|부터|쯤|께|경)(?=[\s,.!?]|$)/;
3
+ /**
4
+ * 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
5
+ * 장소("강남역에서")나 동사("만나기")는 그대로 남긴다.
6
+ */
7
+ export function toEvent(text, results) {
8
+ let rest = text;
9
+ // 뒤에서부터 지워야 앞쪽 index가 어긋나지 않는다
10
+ for (const r of [...results].sort((a, b) => b.index - a.index)) {
11
+ const after = rest.slice(r.index + r.text.length);
12
+ const particle = TRAILING_PARTICLE.exec(after)?.[0] ?? '';
13
+ rest = rest.slice(0, r.index) + ' ' + after.slice(particle.length);
14
+ }
15
+ const title = rest
16
+ .replace(/\s+/g, ' ')
17
+ .replace(/\s+([,.!?])/g, '$1')
18
+ .replace(/^[\s,]+|[\s,]+$/g, '');
19
+ const first = results[0];
20
+ if (!first)
21
+ return { title, date: null, hasTime: false };
22
+ const event = { title, date: first.date, hasTime: first.hasTime };
23
+ if (first.end)
24
+ event.end = first.end;
25
+ if (first.recurrence)
26
+ event.recurrence = first.recurrence;
27
+ return event;
28
+ }
@@ -0,0 +1,55 @@
1
+ import type { ParseOptions, ParseResult } from './types.js';
2
+ import { type ParsedEvent } from './event.js';
3
+ export type { Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
4
+ export type { ParsedEvent } from './event.js';
5
+ /**
6
+ * 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
7
+ *
8
+ * @example
9
+ * parseAll('내일 10시 아니면 모레 2시');
10
+ * // → [{ date: 내일 10:00, text: '내일 10시', ... }, { date: 모레 14:00, text: '모레 2시', ... }]
11
+ *
12
+ * @example
13
+ * parseAll('11월 1일부터 11월 3일까지');
14
+ * // → [{ date: 11-01 00:00, end: 11-03 00:00, ... }] "A부터 B까지"는 하나의 구간으로 합쳐진다
15
+ */
16
+ export declare function parseAll(text: string, options?: ParseOptions): ParseResult[];
17
+ /**
18
+ * 문장에서 첫 번째 날짜/시간 표현을 찾는다. 없으면 null.
19
+ *
20
+ * @example
21
+ * parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?');
22
+ * // → { date: 다음주 수요일 19:00, text: '다음주 수요일 저녁 7시', index: 3, hasTime: true }
23
+ *
24
+ * @example
25
+ * parse('이번달 말까지 보내주세요');
26
+ * // → { date: 기준 시각, end: 이번달 마지막날 23:59, text: '이번달 말까지', hasTime: false, ... }
27
+ *
28
+ * @example
29
+ * parse('오후 3시', { referenceDate: new Date(2026, 9, 30, 14) });
30
+ * // → 2026-10-30 15:00 (시간만 있으면 오늘, 이미 지났으면 내일)
31
+ */
32
+ export declare function parse(text: string, options?: ParseOptions): ParseResult | null;
33
+ /**
34
+ * parse()의 date만 돌려주는 단축형. 구간이면 시작 시각.
35
+ *
36
+ * @example
37
+ * parseDate('다음주 화요일 오후 3시'); // → Date
38
+ * parseDate('아무 날짜도 없음'); // → null
39
+ */
40
+ export declare function parseDate(text: string, options?: ParseOptions): Date | null;
41
+ /**
42
+ * 할 일·일정 한 줄을 제목과 시점으로 나눈다. 날짜로 인식된 부분과 거기 붙은 조사를 뺀 나머지가 제목.
43
+ *
44
+ * @example
45
+ * parseEvent('다음주 금요일까지 보고서 제출');
46
+ * // → { title: '보고서 제출', date: 기준 시각, end: 금요일 23:59, hasTime: false }
47
+ *
48
+ * @example
49
+ * parseEvent('매주 월요일 오전 9시 팀 회의');
50
+ * // → { title: '팀 회의', date: 다음 월요일 09:00, hasTime: true, recurrence: { rrule: 'FREQ=WEEKLY;BYDAY=MO', ... } }
51
+ *
52
+ * @example
53
+ * parseEvent('우유 사기'); // → { title: '우유 사기', date: null, hasTime: false }
54
+ */
55
+ export declare function parseEvent(text: string, options?: ParseOptions): ParsedEvent;
package/dist/index.js ADDED
@@ -0,0 +1,183 @@
1
+ import { relativeDay } from './parsers/relativeDay.js';
2
+ import { offset } from './parsers/offset.js';
3
+ import { weekday } from './parsers/weekday.js';
4
+ import { absoluteDate } from './parsers/absoluteDate.js';
5
+ import { relativeMonth } from './parsers/relativeMonth.js';
6
+ import { relativeYear } from './parsers/relativeYear.js';
7
+ import { time } from './parsers/time.js';
8
+ import { duration } from './parsers/duration.js';
9
+ import { recurrence } from './parsers/recurrence.js';
10
+ import { holiday } from './parsers/holiday.js';
11
+ import { alignEnd, groupFragments, isDayOnly, isTimeOnly, isWeekdayOnly, merge, resolve } from './resolve.js';
12
+ import { fromWallClock, toWallClock } from './util/timezone.js';
13
+ import { toEvent } from './event.js';
14
+ const PARSERS = [relativeDay, offset, weekday, absoluteDate, relativeMonth, relativeYear, time, duration, recurrence, holiday];
15
+ // "~까지" / "~부터" 접미 감지. "3시~5시"의 "~"도 시작 표시로 본다
16
+ const UNTIL = /^\s*(전까지|까지|이내|안에|안으로|내로|중으로)/;
17
+ const FROM = /^\s*(부터|~)\s*/;
18
+ // "이번 주 내내" → 그 기간 전체
19
+ const WHOLE = /^\s*내내/;
20
+ function withDefaults(opts) {
21
+ return {
22
+ referenceDate: opts?.referenceDate ?? new Date(),
23
+ weekStart: opts?.weekStart ?? 'monday',
24
+ defaultHour: opts?.defaultHour ?? 0,
25
+ };
26
+ }
27
+ /** 겹치는 조각 제거: 더 긴 것 우선 */
28
+ function dedupe(frags) {
29
+ const sorted = [...frags].sort((a, b) => b.text.length - a.text.length);
30
+ const kept = [];
31
+ for (const f of sorted) {
32
+ const s = f.index, e = f.index + f.text.length;
33
+ if (kept.some((k) => s < k.index + k.text.length && e > k.index))
34
+ continue;
35
+ kept.push(f);
36
+ }
37
+ return kept;
38
+ }
39
+ /**
40
+ * 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
41
+ *
42
+ * @example
43
+ * parseAll('내일 10시 아니면 모레 2시');
44
+ * // → [{ date: 내일 10:00, text: '내일 10시', ... }, { date: 모레 14:00, text: '모레 2시', ... }]
45
+ *
46
+ * @example
47
+ * parseAll('11월 1일부터 11월 3일까지');
48
+ * // → [{ date: 11-01 00:00, end: 11-03 00:00, ... }] "A부터 B까지"는 하나의 구간으로 합쳐진다
49
+ */
50
+ export function parseAll(text, options) {
51
+ const opts = withDefaults(options);
52
+ const timezone = options?.timezone;
53
+ if (timezone === undefined)
54
+ return parseLocal(text, opts);
55
+ // 그 시간대의 벽시계로 계산하고, 결과를 실제 시점으로 되돌린다 (util/timezone.ts)
56
+ const local = parseLocal(text, { ...opts, referenceDate: toWallClock(opts.referenceDate, timezone) });
57
+ return local.map((r) => {
58
+ const out = { ...r, date: fromWallClock(r.date, timezone) };
59
+ if (r.end)
60
+ out.end = fromWallClock(r.end, timezone);
61
+ return out;
62
+ });
63
+ }
64
+ /** 실행 환경의 로컬 시간대로 계산 */
65
+ function parseLocal(text, opts) {
66
+ const frags = dedupe(PARSERS.flatMap((p) => p(text)));
67
+ const groups = groupFragments(frags, text);
68
+ const results = [];
69
+ const comps = [];
70
+ for (const g of groups) {
71
+ const last = g[g.length - 1];
72
+ const tail = text.slice(last.index + last.text.length);
73
+ const until = UNTIL.exec(tail) ?? WHOLE.exec(tail);
74
+ if (until && until[0].includes('내내'))
75
+ last.components.whole = true;
76
+ else if (until)
77
+ last.components.isEnd = true;
78
+ if (FROM.test(tail))
79
+ last.components.isStart = true;
80
+ const r = resolve(g, opts);
81
+ if (!r)
82
+ continue;
83
+ let end = r._end;
84
+ if (until)
85
+ end += until[0].length;
86
+ delete r._end;
87
+ // 조각이 공백으로 시작할 수 있다 ("오후 3시" 앞의 공백). index는 첫 글자를 가리키게
88
+ const raw = text.slice(g[0].index, end);
89
+ r.index = g[0].index + (raw.length - raw.trimStart().length);
90
+ r.text = raw.trim();
91
+ results.push(r);
92
+ comps.push(merge(g.map((f) => f.components)));
93
+ }
94
+ // "A부터 B까지", "A~B" → 하나의 구간으로 합침
95
+ const merged = [];
96
+ for (let i = 0; i < results.length; i++) {
97
+ const a = results[i], b = results[i + 1];
98
+ const from = FROM.exec(text.slice(a.index + a.text.length));
99
+ // "~"는 바로 뒤에 B가 붙어 있을 때만. "부터"는 B가 "까지"로 끝나거나,
100
+ // 바로 뒤에 붙은 B가 시각·일뿐일 때 ("2시부터 4시", "1일부터 3일")
101
+ const adjacent = b !== undefined && from !== null && b.index === a.index + a.text.length + from[0].length;
102
+ const tilde = from?.[1] === '~' && adjacent;
103
+ const bareFrom = from?.[1] === '부터' && adjacent && (isTimeOnly(comps[i + 1]) || isDayOnly(comps[i + 1]));
104
+ if (b && from && (b.end || tilde || bareFrom)) {
105
+ const bc = comps[i + 1];
106
+ let end = b.end ?? b.date;
107
+ if (isTimeOnly(bc)) {
108
+ end = alignEnd(a.date, bc);
109
+ }
110
+ else {
111
+ // "11월 1일부터 3일까지": 끝에 일만 있으면 시작의 연월을 이어받는다
112
+ // ("11월 28일부터 2일까지"처럼 시작보다 이르면 다음 달)
113
+ if (isDayOnly(bc)) {
114
+ const y = a.date.getFullYear(), m0 = a.date.getMonth();
115
+ end = new Date(y, m0, bc.day, end.getHours(), end.getMinutes());
116
+ if (end < a.date)
117
+ end = new Date(y, m0 + 1, bc.day, end.getHours(), end.getMinutes());
118
+ }
119
+ // "월요일부터 금요일까지": 끝 요일이 시작보다 앞이면 다음 주
120
+ if (isWeekdayOnly(bc) && end < a.date)
121
+ end.setDate(end.getDate() + 7);
122
+ if (!b.end && !b.hasTime)
123
+ end = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 23, 59);
124
+ }
125
+ // "추석 ~ 11월"처럼 끝이 시작보다 앞이면 구간이 아니라 별개 표현
126
+ if (end < a.date) {
127
+ merged.push(a);
128
+ continue;
129
+ }
130
+ merged.push({ ...a, end, text: text.slice(a.index, b.index + b.text.length).trim() });
131
+ i++;
132
+ }
133
+ else {
134
+ merged.push(a);
135
+ }
136
+ }
137
+ return merged;
138
+ }
139
+ /**
140
+ * 문장에서 첫 번째 날짜/시간 표현을 찾는다. 없으면 null.
141
+ *
142
+ * @example
143
+ * parse('그럼 다음주 수요일 저녁 7시에 강남에서 볼까?');
144
+ * // → { date: 다음주 수요일 19:00, text: '다음주 수요일 저녁 7시', index: 3, hasTime: true }
145
+ *
146
+ * @example
147
+ * parse('이번달 말까지 보내주세요');
148
+ * // → { date: 기준 시각, end: 이번달 마지막날 23:59, text: '이번달 말까지', hasTime: false, ... }
149
+ *
150
+ * @example
151
+ * parse('오후 3시', { referenceDate: new Date(2026, 9, 30, 14) });
152
+ * // → 2026-10-30 15:00 (시간만 있으면 오늘, 이미 지났으면 내일)
153
+ */
154
+ export function parse(text, options) {
155
+ return parseAll(text, options)[0] ?? null;
156
+ }
157
+ /**
158
+ * parse()의 date만 돌려주는 단축형. 구간이면 시작 시각.
159
+ *
160
+ * @example
161
+ * parseDate('다음주 화요일 오후 3시'); // → Date
162
+ * parseDate('아무 날짜도 없음'); // → null
163
+ */
164
+ export function parseDate(text, options) {
165
+ return parse(text, options)?.date ?? null;
166
+ }
167
+ /**
168
+ * 할 일·일정 한 줄을 제목과 시점으로 나눈다. 날짜로 인식된 부분과 거기 붙은 조사를 뺀 나머지가 제목.
169
+ *
170
+ * @example
171
+ * parseEvent('다음주 금요일까지 보고서 제출');
172
+ * // → { title: '보고서 제출', date: 기준 시각, end: 금요일 23:59, hasTime: false }
173
+ *
174
+ * @example
175
+ * parseEvent('매주 월요일 오전 9시 팀 회의');
176
+ * // → { title: '팀 회의', date: 다음 월요일 09:00, hasTime: true, recurrence: { rrule: 'FREQ=WEEKLY;BYDAY=MO', ... } }
177
+ *
178
+ * @example
179
+ * parseEvent('우유 사기'); // → { title: '우유 사기', date: null, hasTime: false }
180
+ */
181
+ export function parseEvent(text, options) {
182
+ return toEvent(text, parseAll(text, options));
183
+ }
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 절대 날짜 표현 */
3
+ export declare const absoluteDate: Parser;
@@ -0,0 +1,91 @@
1
+ import { parseKoreanNumber } from '../util/number.js';
2
+ // 2026년 11월 5일 / 26년 11월 5일 / 11월 5일 / 11/5 / 2026-11-05 / 26.11.5 / 11.5(월일로 해석)
3
+ const FULL = /(?:(?<!\d)(\d{4}|\d{2})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일(?!간|\s*동안)/g;
4
+ // "2027-01-01T09:00" (ISO 8601 날짜+시각)
5
+ const ISO = /(?<![\d/.-])(\d{4}|\d{2})[-./]\s*(\d{1,2})[-./]\s*(\d{1,2})(?!\d)(?:T(\d{2}):(\d{2}))?/g;
6
+ const SLASH = /(?<![\d/.-])(\d{1,2})\s*[/.]\s*(\d{1,2})(?![\d/.:])/g;
7
+ // "십일월 오일", "유월 이십오일" (오탐 방지를 위해 월과 일이 모두 있을 때만)
8
+ const HANGUL = /(?<![가-힣])(유|시|[일이삼사오육칠팔구십]{1,2})월\s*([일이삼사오육칠팔구십]{1,3})일(?!요일)/g;
9
+ const HANGUL_MONTH = { 유: 6, 시: 10 };
10
+ // "2027년" 단독 (월이 뒤따르지 않을 때). "26년"은 "26년 동안" 오탐이 있어 4자리만
11
+ const YEAR_ONLY = /(?<!\d)((?:19|20)\d{2})\s*년(?!\s*\d{1,2}\s*월)/g;
12
+ // "11월" 단독, "5일" 단독
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;
16
+ /** 두 자리 연도 확장: 00~69 → 20xx, 70~99 → 19xx */
17
+ const expandYear = (y) => {
18
+ const n = +y;
19
+ if (y.length !== 2)
20
+ return n;
21
+ return n < 70 ? 2000 + n : 1900 + n;
22
+ };
23
+ /** 절대 날짜 표현 */
24
+ export const absoluteDate = (text) => {
25
+ const out = [];
26
+ const taken = [];
27
+ const take = (i, len) => {
28
+ if (taken.some(([s, e]) => i < e && i + len > s))
29
+ return false;
30
+ taken.push([i, i + len]);
31
+ return true;
32
+ };
33
+ for (const m of text.matchAll(ISO)) {
34
+ const month = +m[2], day = +m[3];
35
+ if (month < 1 || month > 12 || day < 1 || day > 31)
36
+ continue;
37
+ if (!take(m.index, m[0].length))
38
+ continue;
39
+ const c = { year: expandYear(m[1]), month, day };
40
+ if (m[4] && +m[4] < 24 && +m[5] < 60)
41
+ Object.assign(c, { hour: +m[4], minute: +m[5], meridiem: true });
42
+ out.push({ text: m[0], index: m.index, components: c });
43
+ }
44
+ for (const m of text.matchAll(FULL)) {
45
+ if (!take(m.index, m[0].length))
46
+ continue;
47
+ const c = { month: +m[2], day: +m[3] };
48
+ if (m[1])
49
+ c.year = expandYear(m[1]);
50
+ out.push({ text: m[0], index: m.index, components: c });
51
+ }
52
+ for (const m of text.matchAll(HANGUL)) {
53
+ const month = HANGUL_MONTH[m[1]] ?? parseKoreanNumber(m[1]);
54
+ const day = parseKoreanNumber(m[2]);
55
+ if (month == null || day == null || month < 1 || month > 12 || day < 1 || day > 31)
56
+ continue;
57
+ if (!take(m.index, m[0].length))
58
+ continue;
59
+ out.push({ text: m[0], index: m.index, components: { month, day } });
60
+ }
61
+ for (const m of text.matchAll(SLASH)) {
62
+ if (!take(m.index, m[0].length))
63
+ continue;
64
+ const month = +m[1], day = +m[2];
65
+ if (month < 1 || month > 12 || day < 1 || day > 31)
66
+ continue;
67
+ out.push({ text: m[0], index: m.index, components: { month, day } });
68
+ }
69
+ for (const m of text.matchAll(YEAR_ONLY)) {
70
+ if (!take(m.index, m[0].length))
71
+ continue;
72
+ out.push({ text: m[0], index: m.index, components: { year: +m[1] } });
73
+ }
74
+ for (const m of text.matchAll(MONTH_ONLY)) {
75
+ if (!take(m.index, m[0].length))
76
+ continue;
77
+ const month = +m[1];
78
+ if (month < 1 || month > 12)
79
+ continue;
80
+ out.push({ text: m[0], index: m.index, components: { month } });
81
+ }
82
+ for (const m of text.matchAll(DAY_ONLY)) {
83
+ if (!take(m.index, m[0].length))
84
+ continue;
85
+ const day = +m[1];
86
+ if (day < 1 || day > 31)
87
+ continue;
88
+ out.push({ text: m[0], index: m.index, components: { day } });
89
+ }
90
+ return out;
91
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 기간 표현. 날짜를 계산하지 않고 길이만 뽑는다. "A부터 N일간"으로 쓰일 때 resolve가 end를 만든다. */
3
+ export declare const duration: Parser;
@@ -0,0 +1,41 @@
1
+ import { parseKoreanNumber } from '../util/number.js';
2
+ // "3일간", "일주일 동안", "2주간", "한 달간", "2시간 동안", "90분간", "이틀간"
3
+ const RE = /(?:(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*(일|주|주일|달|개월|시간|분)|(보름|열흘|이틀|사흘|나흘|닷새|엿새|이레|여드레|아흐레))\s*(간|동안)/g;
4
+ // "3박 4일" → 4일
5
+ const NIGHTS = /(\d+)\s*박\s*(\d+)\s*일/g;
6
+ /** 기간 표현. 날짜를 계산하지 않고 길이만 뽑는다. "A부터 N일간"으로 쓰일 때 resolve가 end를 만든다. */
7
+ export const duration = (text) => {
8
+ const out = [];
9
+ for (const m of text.matchAll(NIGHTS)) {
10
+ if (+m[2] === 0)
11
+ continue;
12
+ out.push({ text: m[0], index: m.index, components: { durationDays: +m[2] } });
13
+ }
14
+ for (const m of text.matchAll(RE)) {
15
+ const n = parseKoreanNumber(m[3] ?? m[1]);
16
+ if (!n)
17
+ continue; // "0일간"은 기간이 아니다
18
+ const c = {};
19
+ switch (m[3] ? '일' : m[2]) {
20
+ case '일':
21
+ c.durationDays = n;
22
+ break;
23
+ case '주':
24
+ case '주일':
25
+ c.durationDays = n * 7;
26
+ break;
27
+ case '달':
28
+ case '개월':
29
+ c.durationMonths = n;
30
+ break;
31
+ case '시간':
32
+ c.durationMinutes = n * 60;
33
+ break;
34
+ case '분':
35
+ c.durationMinutes = n;
36
+ break;
37
+ }
38
+ out.push({ text: m[0], index: m.index, components: c });
39
+ }
40
+ return out;
41
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** 공휴일·기념일·명절. 음력 명절의 양력 날짜는 resolve가 표에서 찾는다 */
3
+ export declare const holiday: Parser;
@@ -0,0 +1,60 @@
1
+ // 양력 고정 공휴일·기념일 → [월, 일]. 여러 단어는 띄어쓰기를 생략해도 된다
2
+ const FIXED = {
3
+ '크리스마스 이브': [12, 24],
4
+ 크리스마스: [12, 25],
5
+ 성탄절: [12, 25],
6
+ 신정: [1, 1],
7
+ 삼일절: [3, 1],
8
+ '근로자의 날': [5, 1],
9
+ 노동절: [5, 1],
10
+ 어린이날: [5, 5],
11
+ 어버이날: [5, 8],
12
+ '스승의 날': [5, 15],
13
+ 현충일: [6, 6],
14
+ 제헌절: [7, 17],
15
+ 광복절: [8, 15],
16
+ 개천절: [10, 3],
17
+ 한글날: [10, 9],
18
+ 할로윈: [10, 31],
19
+ 핼러윈: [10, 31],
20
+ 빼빼로데이: [11, 11],
21
+ 발렌타인데이: [2, 14],
22
+ 밸런타인데이: [2, 14],
23
+ 화이트데이: [3, 14],
24
+ };
25
+ const SHIFT = { 전날: -1, 다음날: 1, 이튿날: 1, 당일: 0 };
26
+ const MODIFIER = '(?:\\s*(전날|다음\\s*날|이튿날|당일))?';
27
+ // "크리스마스", "광복절 전날", "스승의날"
28
+ const FIXED_RE = new RegExp(`(${Object.keys(FIXED).map((k) => k.replace(/ /g, '\\s*')).join('|')})${MODIFIER}`, 'g');
29
+ // "설날", "구정", "설 연휴", "추석 전날", "한가위", "부처님 오신 날". "설"만은 "설명", "설거지"와 겹쳐서 뒤에 연휴·전날 등이 올 때만
30
+ const LUNAR_RE = /(설날|구정|설(?=\s*(?:연휴|전날|다음\s*날|당일))|추석|한가위|부처님\s*오신\s*날|석가\s*탄신일|석탄일|초파일)(?:\s*(연휴))?(?:\s*(전날|다음\s*날|이튿날|당일))?/g;
31
+ const fixedKey = (name) => Object.keys(FIXED).find((k) => k.replace(/ /g, '') === name.replace(/\s/g, ''));
32
+ const shiftOf = (m) => (m ? SHIFT[m.replace(/\s/g, '')] : 0);
33
+ /** 공휴일·기념일·명절. 음력 명절의 양력 날짜는 resolve가 표에서 찾는다 */
34
+ export const holiday = (text) => {
35
+ const out = [];
36
+ for (const m of text.matchAll(FIXED_RE)) {
37
+ const [month, day] = FIXED[fixedKey(m[1])];
38
+ const c = { month, day };
39
+ const shift = shiftOf(m[2]);
40
+ if (shift)
41
+ c.dayOffset = shift;
42
+ out.push({ text: m[0], index: m.index, components: c });
43
+ }
44
+ for (const m of text.matchAll(LUNAR_RE)) {
45
+ const name = m[1].replace(/\s/g, '');
46
+ const lunar = name === '설날' || name === '구정' || name === '설' ? 'seollal'
47
+ : name === '추석' || name === '한가위' ? 'chuseok' : 'buddha';
48
+ const c = { lunar };
49
+ // "설 연휴", "추석 연휴" = 전날부터 다음날까지 3일 (부처님 오신 날은 연휴가 없다)
50
+ if (m[2] && lunar !== 'buddha')
51
+ Object.assign(c, { dayOffset: -1, durationDays: 3 });
52
+ else {
53
+ const shift = shiftOf(m[3]);
54
+ if (shift)
55
+ c.dayOffset = shift;
56
+ }
57
+ out.push({ text: m[0], index: m.index, components: c });
58
+ }
59
+ return out;
60
+ };
@@ -0,0 +1,3 @@
1
+ import type { Parser } from '../types.js';
2
+ /** "N일 뒤 / N주 후 / N달 전 / N시간 뒤 / N분 후" */
3
+ export declare const offset: Parser;
@@ -0,0 +1,60 @@
1
+ import { parseKoreanNumber } from '../util/number.js';
2
+ // "3일 뒤", "2주 후", "한 달 전", "세 시간 뒤", "30분 후", "열흘 뒤", "이틀 뒤", "1년 뒤", "30분 내로", "30분쯤 뒤"
3
+ const RE = /(?:(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*(일|주|주일|달|개월|년|시간|분)|(보름|열흘|이틀|사흘|나흘|닷새|엿새|이레|여드레|아흐레))\s*(?:쯤|정도)?\s*(뒤|후|전|이내|안에|안으로|내로|이후)/g;
4
+ // "한 시간 반 뒤", "1시간 30분 후", "2시간반 전"
5
+ const HOUR_MIN = /(\d+|[일이삼사오육칠팔구십한두세네다섯여섯일곱여덟아홉열]+)\s*시간\s*(?:(반)|(\d+)\s*분)\s*(?:쯤|정도)?\s*(뒤|후|전|이내|안에|이후)/g;
6
+ /** "N일 뒤 / N주 후 / N달 전 / N시간 뒤 / N분 후" */
7
+ export const offset = (text) => {
8
+ const out = [];
9
+ const taken = [];
10
+ for (const m of text.matchAll(HOUR_MIN)) {
11
+ const h = parseKoreanNumber(m[1]);
12
+ if (h == null)
13
+ continue;
14
+ const sign = m[4] === '전' ? -1 : 1;
15
+ const c = { hourOffset: sign * h, minuteOffset: sign * (m[2] ? 30 : +m[3]) };
16
+ if (m[4] === '이내' || m[4] === '안에')
17
+ c.isEnd = true;
18
+ out.push({ text: m[0], index: m.index, components: c });
19
+ taken.push([m.index, m.index + m[0].length]);
20
+ }
21
+ for (const m of text.matchAll(RE)) {
22
+ if (taken.some(([s, e]) => m.index < e && m.index + m[0].length > s))
23
+ continue;
24
+ const n = parseKoreanNumber(m[3] ?? m[1]);
25
+ if (n == null)
26
+ continue;
27
+ const sign = m[4] === '전' ? -1 : 1;
28
+ const c = {};
29
+ switch (m[3] ? '일' : m[2]) {
30
+ case '일':
31
+ c.dayOffset = sign * n;
32
+ break;
33
+ case '주':
34
+ case '주일':
35
+ c.dayOffset = sign * n * 7;
36
+ break;
37
+ case '달':
38
+ case '개월':
39
+ c.monthOffset = sign * n;
40
+ break;
41
+ case '년':
42
+ // "2028년 후"는 "2028년 뒤"가 아니라 "2028년 이후"일 가능성이 크다. 100년 넘게는 상대 이동으로 보지 않는다
43
+ if (n > 100)
44
+ continue;
45
+ c.yearOffset = sign * n;
46
+ c.sameDay = true;
47
+ break;
48
+ case '시간':
49
+ c.hourOffset = sign * n;
50
+ break;
51
+ case '분':
52
+ c.minuteOffset = sign * n;
53
+ break;
54
+ }
55
+ if (['이내', '안에', '안으로', '내로'].includes(m[4]))
56
+ c.isEnd = true;
57
+ out.push({ text: m[0], index: m.index, components: c });
58
+ }
59
+ return out;
60
+ };
@@ -0,0 +1,5 @@
1
+ import type { Parser, Recurrence } from '../types.js';
2
+ /** 반복 일정. 날짜는 계산하지 않고 규칙만 뽑는다 (첫 발생일은 resolve가 계산) */
3
+ export declare const recurrence: Parser;
4
+ /** RFC 5545 RRULE 문자열. 요일은 월요일부터 */
5
+ export declare function toRRule(r: Omit<Recurrence, 'rrule'>): string;
@@ -0,0 +1,112 @@
1
+ import { parseKoreanNumber } from '../util/number.js';
2
+ const WEEKDAY = { 일: 0, 월: 1, 화: 2, 수: 3, 목: 4, 금: 5, 토: 6 };
3
+ const NTH = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 마지막: -1 };
4
+ const SETS = { 평일: [1, 2, 3, 4, 5], 주중: [1, 2, 3, 4, 5], 주말: [6, 0] };
5
+ // 요일 목록 "월수금", "화, 목", "월요일이랑 수요일", "월~금". 목록이 끝난 뒤에는 다른 한글이 붙으면 안 된다
6
+ // ("매주 일요일 일정"의 "일"을 요일로 잡지 않게). 조사·마다는 허용
7
+ const ITEM = '[월화수목금토일](?:요일|욜)?(?:\\s*~\\s*[월화수목금토일](?:요일|욜)?)?';
8
+ const LIST = `${ITEM}(?:\\s*(?:,|·|/|이랑|랑|하고|과|와)?\\s*${ITEM})*`;
9
+ const LIST_END = '(?:(?![가-힣])|(?=마다|에|엔|은|는|도))';
10
+ // "매달 첫째 주 월요일", "매월 마지막 주 금요일"
11
+ const MONTHLY_NTH = /(?:매달|매월|매\s*달)\s*(첫째|첫|둘째|셋째|넷째|마지막)\s*주\s*([월화수목금토일])(?:요일|욜)/g;
12
+ // "매달 15일", "매월 말일", "매달 말", "매달 초"
13
+ const MONTHLY_DAY = /(?:매달|매월|매\s*달|다달이)\s*(?:(\d{1,2})\s*일|(말일|마지막\s*날|말)|(초))/g;
14
+ // "매년 3월 1일", "해마다 12월 25일"
15
+ const YEARLY = /(?:매년|매해|해마다)\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;
16
+ // "매주 평일", "평일마다", "주말마다", "평일 매일"
17
+ const WEEKDAY_SET = /매주\s*(평일|주중|주말)|(?<![가-힣])(평일|주중|주말)\s*(?:마다|매일)/g;
18
+ // "매주 월요일", "매주 월수금", "격주 금요일", "2주마다 수요일", "2주에 한 번 화요일"
19
+ const WEEKLY = new RegExp(`(매주|매\\s*주|격주|(\\d{1,2}|두|세|네)\\s*주\\s*(?:마다|에\\s*한\\s*번))\\s*(${LIST})${LIST_END}(?:\\s*마다)?`, 'g');
20
+ // "월요일마다", "화요일이랑 목요일마다"
21
+ const DAYS_EVERY = /(?<![가-힣])([월화수목금토일](?:요일|욜)(?:\s*(?:,|·|이랑|랑|하고|과|와)?\s*[월화수목금토일](?:요일|욜))*)\s*마다/g;
22
+ // "매일", "날마다", "매일매일"
23
+ const DAILY = /(?<![가-힣])(?:매일매일|매일|날마다)/g;
24
+ // "2시간마다", "30분마다", "매시간"
25
+ const HOURLY = /(?<![가-힣\d])(?:(\d{1,2}|한|두|세|네|다섯|여섯)\s*시간\s*마다|매\s*시간)/g;
26
+ const MINUTELY = /(?<!\d)(\d{1,3})\s*분\s*마다/g;
27
+ /** "월수금", "화, 목요일", "월~금" → 요일 번호들. "요일" 안의 "일"은 일요일이 아니다 */
28
+ function weekdaysOf(list) {
29
+ const out = [];
30
+ const plain = list.replace(/요일|욜/g, '');
31
+ // "월~금" → 월화수목금 (월요일부터 센다, "금~일"은 금토일)
32
+ for (const m of plain.matchAll(/([월화수목금토일])\s*~\s*([월화수목금토일])|([월화수목금토일])/g)) {
33
+ if (m[3]) {
34
+ out.push(WEEKDAY[m[3]]);
35
+ continue;
36
+ }
37
+ const from = (WEEKDAY[m[1]] + 6) % 7, to = (WEEKDAY[m[2]] + 6) % 7;
38
+ for (let i = from; i !== (to + 1) % 7; i = (i + 1) % 7)
39
+ out.push((i + 1) % 7);
40
+ }
41
+ return [...new Set(out)];
42
+ }
43
+ /** 반복 일정. 날짜는 계산하지 않고 규칙만 뽑는다 (첫 발생일은 resolve가 계산) */
44
+ export const recurrence = (text) => {
45
+ const out = [];
46
+ const taken = [];
47
+ const push = (m, recur) => {
48
+ const s = m.index, e = s + m[0].length;
49
+ if (taken.some(([a, b]) => s < b && e > a))
50
+ return;
51
+ taken.push([s, e]);
52
+ out.push({ text: m[0], index: s, components: { recur } });
53
+ };
54
+ for (const m of text.matchAll(MONTHLY_NTH)) {
55
+ push(m, { freq: 'MONTHLY', interval: 1, byWeekday: [WEEKDAY[m[2]]], byWeekdayNth: NTH[m[1]] });
56
+ }
57
+ for (const m of text.matchAll(MONTHLY_DAY)) {
58
+ const day = m[1] ? +m[1] : m[2] ? -1 : 1;
59
+ if (day === 0 || day > 31)
60
+ continue;
61
+ push(m, { freq: 'MONTHLY', interval: 1, byMonthDay: day });
62
+ }
63
+ for (const m of text.matchAll(YEARLY)) {
64
+ const month = +m[1], day = +m[2];
65
+ if (month < 1 || month > 12 || day < 1 || day > 31)
66
+ continue;
67
+ push(m, { freq: 'YEARLY', interval: 1, byMonth: month, byMonthDay: day });
68
+ }
69
+ for (const m of text.matchAll(WEEKDAY_SET)) {
70
+ push(m, { freq: 'WEEKLY', interval: 1, byWeekday: SETS[m[1] ?? m[2]] });
71
+ }
72
+ for (const m of text.matchAll(WEEKLY)) {
73
+ const n = m[1].startsWith('격주') ? 2 : m[2] ? parseKoreanNumber(m[2]) : 1;
74
+ if (!n)
75
+ continue;
76
+ push(m, { freq: 'WEEKLY', interval: n, byWeekday: weekdaysOf(m[3]) });
77
+ }
78
+ for (const m of text.matchAll(DAYS_EVERY)) {
79
+ push(m, { freq: 'WEEKLY', interval: 1, byWeekday: weekdaysOf(m[1]) });
80
+ }
81
+ for (const m of text.matchAll(DAILY)) {
82
+ push(m, { freq: 'DAILY', interval: 1 });
83
+ }
84
+ for (const m of text.matchAll(HOURLY)) {
85
+ const n = m[1] ? parseKoreanNumber(m[1]) : 1;
86
+ if (!n)
87
+ continue;
88
+ push(m, { freq: 'HOURLY', interval: n });
89
+ }
90
+ for (const m of text.matchAll(MINUTELY)) {
91
+ if (+m[1] === 0)
92
+ continue;
93
+ push(m, { freq: 'MINUTELY', interval: +m[1] });
94
+ }
95
+ return out;
96
+ };
97
+ const CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
98
+ /** RFC 5545 RRULE 문자열. 요일은 월요일부터 */
99
+ export function toRRule(r) {
100
+ const parts = [`FREQ=${r.freq}`];
101
+ if (r.interval > 1)
102
+ parts.push(`INTERVAL=${r.interval}`);
103
+ if (r.byMonth !== undefined)
104
+ parts.push(`BYMONTH=${r.byMonth}`);
105
+ if (r.byMonthDay !== undefined)
106
+ parts.push(`BYMONTHDAY=${r.byMonthDay}`);
107
+ if (r.byWeekday?.length) {
108
+ const days = [...r.byWeekday].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7)).map((d) => CODE[d]);
109
+ parts.push(`BYDAY=${days.map((c) => (r.byWeekdayNth ? `${r.byWeekdayNth}${c}` : c)).join(',')}`);
110
+ }
111
+ return parts.join(';');
112
+ }