ko-chrono 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +146 -0
- package/README.md +112 -151
- package/dist/cjs/ics.d.ts +30 -0
- package/dist/cjs/ics.js +80 -0
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.js +93 -3
- package/dist/cjs/parsers/absoluteDate.js +2 -2
- package/dist/cjs/parsers/holiday.js +1 -0
- package/dist/cjs/parsers/offset.js +2 -0
- package/dist/cjs/parsers/recurrence.d.ts +6 -2
- package/dist/cjs/parsers/recurrence.js +15 -2
- package/dist/cjs/parsers/time.js +4 -0
- package/dist/cjs/parsers/weekday.js +3 -3
- package/dist/cjs/resolve.js +31 -5
- package/dist/cjs/types.d.ts +9 -1
- package/dist/ics.d.ts +30 -0
- package/dist/ics.js +77 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +92 -4
- package/dist/parsers/absoluteDate.js +2 -2
- package/dist/parsers/holiday.js +1 -0
- package/dist/parsers/offset.js +2 -0
- package/dist/parsers/recurrence.d.ts +6 -2
- package/dist/parsers/recurrence.js +15 -2
- package/dist/parsers/time.js +4 -0
- package/dist/parsers/weekday.js +3 -3
- package/dist/resolve.js +31 -5
- package/dist/types.d.ts +9 -1
- package/package.json +18 -4
package/dist/cjs/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ParseOptions, ParseResult } from './types.js';
|
|
|
2
2
|
import { type ParsedEvent } from './event.js';
|
|
3
3
|
export type { Ambiguity, Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
|
|
4
4
|
export type { ParsedEvent } from './event.js';
|
|
5
|
+
export { toICS, type ICSOptions } from './ics.js';
|
|
5
6
|
/**
|
|
6
7
|
* 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
|
|
7
8
|
*
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toICS = void 0;
|
|
3
4
|
exports.parseAll = parseAll;
|
|
4
5
|
exports.parse = parse;
|
|
5
6
|
exports.parseDate = parseDate;
|
|
@@ -16,13 +17,22 @@ const recurrence_js_1 = require("./parsers/recurrence.js");
|
|
|
16
17
|
const holiday_js_1 = require("./parsers/holiday.js");
|
|
17
18
|
const resolve_js_1 = require("./resolve.js");
|
|
18
19
|
const timezone_js_1 = require("./util/timezone.js");
|
|
20
|
+
const number_js_1 = require("./util/number.js");
|
|
19
21
|
const event_js_1 = require("./event.js");
|
|
22
|
+
var ics_js_1 = require("./ics.js");
|
|
23
|
+
Object.defineProperty(exports, "toICS", { enumerable: true, get: function () { return ics_js_1.toICS; } });
|
|
20
24
|
const PARSERS = [relativeDay_js_1.relativeDay, offset_js_1.offset, weekday_js_1.weekday, absoluteDate_js_1.absoluteDate, relativeMonth_js_1.relativeMonth, relativeYear_js_1.relativeYear, time_js_1.time, duration_js_1.duration, recurrence_js_1.recurrence, holiday_js_1.holiday];
|
|
21
|
-
// "~까지" / "~부터" 접미 감지. "3시~5시"의 "~"도 시작 표시로
|
|
22
|
-
const UNTIL = /^\s*(
|
|
25
|
+
// "~까지" / "~부터" 접미 감지. "3시~5시"의 "~"도 시작 표시로 본다. "다음주 중", "오늘 중에"도 마감
|
|
26
|
+
const UNTIL = /^\s*(전까지|까지|이내|안에|안으로|내로|중으로|중에(?![가-힣])|중(?![가-힣]))/;
|
|
23
27
|
const FROM = /^\s*(부터|~)\s*/;
|
|
24
28
|
// "이번 주 내내" → 그 기간 전체
|
|
25
29
|
const WHOLE = /^\s*내내/;
|
|
30
|
+
// 반복 일정과 그 앞뒤 날짜 사이 (규칙 34): "다음달까지 매주 월요일", "매주 월요일, 12월 20일까지"
|
|
31
|
+
const GAP = /^[\s,]*$/;
|
|
32
|
+
// "다음주부터 매주 월요일"
|
|
33
|
+
const FROM_GAP = /^\s*부터[\s,]*$/;
|
|
34
|
+
// "총 8회", "10번 반복", "총 열 번". "매일 3번"은 하루 횟수일 수 있어 "총"이나 "반복"이 있을 때만
|
|
35
|
+
const COUNT = /^[\s,]*(?:총\s*(\d{1,3}|[한두세네다섯여섯일곱여덟아홉열스무]+)\s*(?:번|회)(?:\s*반복)?|(\d{1,3}|[한두세네다섯여섯일곱여덟아홉열스무]+)\s*(?:번|회)\s*반복)/;
|
|
26
36
|
function withDefaults(opts) {
|
|
27
37
|
return {
|
|
28
38
|
referenceDate: opts?.referenceDate ?? new Date(),
|
|
@@ -64,6 +74,9 @@ function parseAll(text, options) {
|
|
|
64
74
|
const out = { ...r, date: (0, timezone_js_1.fromWallClock)(r.date, timezone) };
|
|
65
75
|
if (r.end)
|
|
66
76
|
out.end = (0, timezone_js_1.fromWallClock)(r.end, timezone);
|
|
77
|
+
// rrule의 UNTIL은 벽시계 그대로 두고, Date 값만 실제 시점으로
|
|
78
|
+
if (r.recurrence?.until)
|
|
79
|
+
out.recurrence = { ...r.recurrence, until: (0, timezone_js_1.fromWallClock)(r.recurrence.until, timezone) };
|
|
67
80
|
if (r.ambiguity) {
|
|
68
81
|
out.ambiguity = { ...r.ambiguity, alternatives: r.ambiguity.alternatives.map((a) => (0, timezone_js_1.fromWallClock)(a, timezone)) };
|
|
69
82
|
}
|
|
@@ -76,6 +89,7 @@ function parseLocal(text, opts) {
|
|
|
76
89
|
const groups = (0, resolve_js_1.groupFragments)(frags, text);
|
|
77
90
|
const results = [];
|
|
78
91
|
const comps = [];
|
|
92
|
+
const resultGroups = [];
|
|
79
93
|
for (const g of groups) {
|
|
80
94
|
const last = g[g.length - 1];
|
|
81
95
|
const tail = text.slice(last.index + last.text.length);
|
|
@@ -99,10 +113,15 @@ function parseLocal(text, opts) {
|
|
|
99
113
|
r.text = raw.trim();
|
|
100
114
|
results.push(r);
|
|
101
115
|
comps.push((0, resolve_js_1.merge)(g.map((f) => f.components)));
|
|
116
|
+
resultGroups.push(g);
|
|
102
117
|
}
|
|
103
118
|
// "A부터 B까지", "A~B" → 하나의 구간으로 합침
|
|
104
119
|
const merged = [];
|
|
120
|
+
const mergedGroups = [];
|
|
121
|
+
const mergedComps = [];
|
|
105
122
|
for (let i = 0; i < results.length; i++) {
|
|
123
|
+
mergedGroups.push(resultGroups[i]);
|
|
124
|
+
mergedComps.push(comps[i]);
|
|
106
125
|
const a = results[i], b = results[i + 1];
|
|
107
126
|
const from = FROM.exec(text.slice(a.index + a.text.length));
|
|
108
127
|
// "~"는 바로 뒤에 B가 붙어 있을 때만. "부터"는 B가 "까지"로 끝나거나,
|
|
@@ -143,7 +162,78 @@ function parseLocal(text, opts) {
|
|
|
143
162
|
merged.push(a);
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
|
-
return merged;
|
|
165
|
+
return bindRecurrence(text, merged, mergedGroups, mergedComps, opts);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 반복 일정 앞뒤의 날짜를 시작·끝으로 묶는다 (규칙 34).
|
|
169
|
+
* - 앞이나 뒤의 마감·구간 ("다음달까지", "11월 1일부터 12월 31일까지") → 그 시작 이후 첫 발생, 끝은 UNTIL
|
|
170
|
+
* - 앞의 "A부터" → A 이후 첫 발생
|
|
171
|
+
* - 뒤의 "총 N회", "N번 반복" → COUNT
|
|
172
|
+
* 끝이 첫 발생보다 앞이면 묶지 않는다 ("어제까지 매주 월요일").
|
|
173
|
+
*/
|
|
174
|
+
function bindRecurrence(text, results, groups, comps, opts) {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (let i = 0; i < results.length; i++) {
|
|
177
|
+
let r = results[i];
|
|
178
|
+
if (!r.recurrence) {
|
|
179
|
+
out.push(r);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let from = r.index;
|
|
183
|
+
let to = r.index + r.text.length;
|
|
184
|
+
let bound = false;
|
|
185
|
+
// 시작·끝이 되는 날짜를 적용해 첫 발생을 다시 계산한다
|
|
186
|
+
// 시각만 있는 마감("5시까지 매일")은 그날 하루의 끝이지 반복의 끝이 아니다
|
|
187
|
+
const bindTo = (other, j) => {
|
|
188
|
+
if ((0, resolve_js_1.isTimeOnly)(comps[j]))
|
|
189
|
+
return false;
|
|
190
|
+
// 마감("다음달까지")의 date는 기준 시각이라 그대로, 지난 시작("지난주부터")은 기준 시각부터
|
|
191
|
+
const begin = other.date > opts.referenceDate ? other.date : opts.referenceDate;
|
|
192
|
+
const again = (0, resolve_js_1.resolve)(groups[i], { ...opts, referenceDate: begin });
|
|
193
|
+
if (!again?.recurrence)
|
|
194
|
+
return false;
|
|
195
|
+
// "N주 동안"은 새 시작부터 다시 센 값(again)을 쓴다
|
|
196
|
+
const until = again.recurrence.until ?? other.end;
|
|
197
|
+
if (until && until < again.date)
|
|
198
|
+
return false;
|
|
199
|
+
const shift = again.date.getTime() - r.date.getTime();
|
|
200
|
+
r = { ...r, date: again.date, recurrence: { ...again.recurrence, until } };
|
|
201
|
+
if (r.end)
|
|
202
|
+
r.end = new Date(r.end.getTime() + shift);
|
|
203
|
+
return true;
|
|
204
|
+
};
|
|
205
|
+
const prev = out[out.length - 1];
|
|
206
|
+
const prevIndex = results.indexOf(prev);
|
|
207
|
+
if (prev && !prev.recurrence) {
|
|
208
|
+
const gap = text.slice(prev.index + prev.text.length, r.index);
|
|
209
|
+
if ((prev.end ? GAP : FROM_GAP).test(gap) && bindTo(prev, prevIndex)) {
|
|
210
|
+
from = prev.index;
|
|
211
|
+
out.pop();
|
|
212
|
+
bound = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const next = results[i + 1];
|
|
216
|
+
if (!r.recurrence.until && next?.end && !next.recurrence && GAP.test(text.slice(to, next.index)) && bindTo(next, i + 1)) {
|
|
217
|
+
to = next.index + next.text.length;
|
|
218
|
+
bound = true;
|
|
219
|
+
i++;
|
|
220
|
+
}
|
|
221
|
+
const count = COUNT.exec(text.slice(to));
|
|
222
|
+
const n = count ? (0, number_js_1.parseKoreanNumber)(count[1] ?? count[2]) : null;
|
|
223
|
+
if (n && !r.recurrence.until) {
|
|
224
|
+
r = { ...r, recurrence: { ...r.recurrence, count: n } };
|
|
225
|
+
to += count[0].length;
|
|
226
|
+
bound = true;
|
|
227
|
+
}
|
|
228
|
+
if (bound) {
|
|
229
|
+
const rule = r.recurrence;
|
|
230
|
+
if (rule.until === undefined)
|
|
231
|
+
delete rule.until;
|
|
232
|
+
r = { ...r, index: from, text: text.slice(from, to).trim(), recurrence: { ...rule, rrule: (0, recurrence_js_1.toRRule)(rule, !r.hasTime) } };
|
|
233
|
+
}
|
|
234
|
+
out.push(r);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
147
237
|
}
|
|
148
238
|
/**
|
|
149
239
|
* 문장에서 첫 번째 날짜/시간 표현을 찾는다. 없으면 null.
|
|
@@ -14,8 +14,8 @@ const HANGUL_MONTH = { 유: 6, 시: 10 };
|
|
|
14
14
|
const YEAR_ONLY = /(?<!\d)((?:19|20)\d{2})\s*년(?!\s*\d{1,2}\s*월)/g;
|
|
15
15
|
// "11월" 단독, "5일" 단독
|
|
16
16
|
const MONTH_ONLY = /(?<![\d가-힣])(\d{1,2})\s*월(?!\s*\d{1,2}\s*일|생|세)/g;
|
|
17
|
-
// "5일간", "3일 동안", "3박 4일"은 기간이지 날짜가 아니다
|
|
18
|
-
const DAY_ONLY = /(?<!\d)(?<!\d\s*박\s*)(\d{1,2})\s*일(?!요일|간|장|째|차|분|\s*동안|\s
|
|
17
|
+
// "5일간", "3일 동안", "3박 4일"은 기간이지 날짜가 아니다. "주 5일 근무"(횟수), "30일 챌린지"도 아니다
|
|
18
|
+
const DAY_ONLY = /(?<!\d)(?<!\d\s*박\s*)(?<!(?<![가-힣])주\s*)(\d{1,2})\s*일(?!요일|간|장|째|차|분|\s*동안|\s*연속|\s*챌린지)/g;
|
|
19
19
|
/** 두 자리 연도 확장: 00~69 → 20xx, 70~99 → 19xx */
|
|
20
20
|
const expandYear = (y) => {
|
|
21
21
|
const n = +y;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Parser, Recurrence } from '../types.js';
|
|
2
2
|
/** 반복 일정. 날짜는 계산하지 않고 규칙만 뽑는다 (첫 발생일은 resolve가 계산) */
|
|
3
3
|
export declare const recurrence: Parser;
|
|
4
|
-
/**
|
|
5
|
-
|
|
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;
|
|
@@ -100,8 +100,13 @@ const recurrence = (text) => {
|
|
|
100
100
|
};
|
|
101
101
|
exports.recurrence = recurrence;
|
|
102
102
|
const CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
104
|
+
/**
|
|
105
|
+
* RFC 5545 RRULE 문자열. 요일은 월요일부터.
|
|
106
|
+
* UNTIL은 벽시계 시각 그대로 쓴다 (DTSTART가 TZID 또는 floating time일 때의 형식).
|
|
107
|
+
* dateOnly면 DTSTART가 DATE이므로 UNTIL도 날짜만.
|
|
108
|
+
*/
|
|
109
|
+
function toRRule(r, dateOnly = false) {
|
|
105
110
|
const parts = [`FREQ=${r.freq}`];
|
|
106
111
|
if (r.interval > 1)
|
|
107
112
|
parts.push(`INTERVAL=${r.interval}`);
|
|
@@ -113,5 +118,13 @@ function toRRule(r) {
|
|
|
113
118
|
const days = [...r.byWeekday].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7)).map((d) => CODE[d]);
|
|
114
119
|
parts.push(`BYDAY=${days.map((c) => (r.byWeekdayNth ? `${r.byWeekdayNth}${c}` : c)).join(',')}`);
|
|
115
120
|
}
|
|
121
|
+
if (r.until) {
|
|
122
|
+
const u = r.until;
|
|
123
|
+
const date = `${u.getFullYear()}${pad(u.getMonth() + 1)}${pad(u.getDate())}`;
|
|
124
|
+
parts.push(`UNTIL=${dateOnly ? date : `${date}T${pad(u.getHours())}${pad(u.getMinutes())}${pad(u.getSeconds())}`}`);
|
|
125
|
+
}
|
|
126
|
+
else if (r.count) {
|
|
127
|
+
parts.push(`COUNT=${r.count}`);
|
|
128
|
+
}
|
|
116
129
|
return parts.join(';');
|
|
117
130
|
}
|
package/dist/cjs/parsers/time.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.time = void 0;
|
|
4
4
|
const number_js_1 = require("../util/number.js");
|
|
5
|
+
// "24시 편의점", "24시 영업": 하루 종일 연다는 뜻이지 시각이 아니다
|
|
6
|
+
const OPEN_ALL_DAY = /^\s*(?:편의점|영업|운영|무인|사우나|찜질방|약국|병원|동물병원|매장|카페|식당|해장국|김밥|헬스|콜센터|상담)/;
|
|
5
7
|
// "오후 3시", "3시 반", "오전 10시 30분", "새벽 2시", "저녁 7시", "낮 12시", "15시", "7시30", "3시 정각"
|
|
6
8
|
const CLOCK = /(오전|오후|새벽|아침|저녁|밤|낮|정오)?\s*(\d{1,2}|한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열두)\s*시(?!간)\s*(?:(반)|(\d{1,2})\s*분|(\d{2})(?![\d가-힣])|정각)?/g;
|
|
7
9
|
// "5시 10분 전" → 4:50
|
|
@@ -100,6 +102,8 @@ const time = (text) => {
|
|
|
100
102
|
const raw = (0, number_js_1.parseKoreanNumber)(m[2]);
|
|
101
103
|
if (raw == null || raw > 24)
|
|
102
104
|
continue;
|
|
105
|
+
if (raw === 24 && !m[1] && OPEN_ALL_DAY.test(text.slice(m.index + m[0].length)))
|
|
106
|
+
continue;
|
|
103
107
|
if (!take(m.index, m[0].length))
|
|
104
108
|
continue;
|
|
105
109
|
const hour = applyMeridiem(m[1], raw);
|
|
@@ -25,8 +25,8 @@ const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:
|
|
|
25
25
|
// "전주"는 도시 이름과 너무 자주 겹쳐서 요일이 붙을 때만
|
|
26
26
|
const FORMAL_WEEK = { 전전: -2, 전: -1, 금: 0, 차: 1, 익: 1, 차차: 2 };
|
|
27
27
|
const FORMAL_WITH_DAY = new RegExp(`(?<![가-힣])(전전|차차|전|금|차|익)주\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
28
|
-
// "차주 중으로", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
|
|
29
|
-
const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)
|
|
28
|
+
// "차주 중으로", "차주 중", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
|
|
29
|
+
const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)|에는|중으로|중에|중(?![가-힣])|내로|내|안에|안으로|까지|초|말|부터))|(금)주(?=\s*(?:중으로|중에|중(?![가-힣])|내로|내|안에|안으로|까지|초|말|부터)))/g;
|
|
30
30
|
// "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
|
|
31
31
|
const DIR = new RegExp(`(지난|저번|다음)\\s*(${DAY})(?:요일|욜)`, 'g');
|
|
32
32
|
// "주말에 보자" ("이번 주말"은 위 WEEKEND가 먼저 잡는다)
|
|
@@ -88,7 +88,7 @@ const weekday = (text) => {
|
|
|
88
88
|
taken.push([m.index, m.index + m[0].length]);
|
|
89
89
|
}
|
|
90
90
|
for (const m of text.matchAll(WEEKEND)) {
|
|
91
|
-
out.push({ text: m[0], index: m.index, components: { weekOffset: WEEK_WORDS[m[1]], weekday: 6 } });
|
|
91
|
+
out.push({ text: m[0], index: m.index, components: { weekOffset: WEEK_WORDS[m[1]], weekday: 6, weekend: true } });
|
|
92
92
|
taken.push([m.index, m.index + m[0].length]);
|
|
93
93
|
}
|
|
94
94
|
for (const m of text.matchAll(BARE_WEEKEND)) {
|
package/dist/cjs/resolve.js
CHANGED
|
@@ -76,6 +76,7 @@ const DATE_KEYS = [
|
|
|
76
76
|
/** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
|
|
77
77
|
function isTimeOnly(c) {
|
|
78
78
|
return (c.hour !== undefined &&
|
|
79
|
+
c.recur === undefined &&
|
|
79
80
|
c.hourOffset === undefined &&
|
|
80
81
|
c.minuteOffset === undefined &&
|
|
81
82
|
!DATE_KEYS.some((k) => c[k] !== undefined));
|
|
@@ -119,7 +120,9 @@ function hasConflict(a, b) {
|
|
|
119
120
|
const weekdayNote = (x, y) => x.day !== undefined && y.weekday !== undefined && !y.weekend &&
|
|
120
121
|
y.weekOffset === undefined && y.day === undefined && y.dayOffset === undefined && y.monthPart === undefined;
|
|
121
122
|
const dayConflict = both(dayKeys) && !weekdayNote(a, b) && !weekdayNote(b, a);
|
|
122
|
-
|
|
123
|
+
// 규칙 34: "다음주부터 매주 월요일"의 날짜는 반복의 시작·끝이라 따로 해석한 뒤 parseAll이 묶는다
|
|
124
|
+
const recurVsDate = (a.recur !== undefined && has(b, dateKeys)) || (b.recur !== undefined && has(a, dateKeys));
|
|
125
|
+
return dayConflict || both(timeKeys) || dateVsRel || recurVsDate;
|
|
123
126
|
}
|
|
124
127
|
function resolve(group, opts) {
|
|
125
128
|
const c = merge(group.map((f) => f.components));
|
|
@@ -168,7 +171,15 @@ function resolve(group, opts) {
|
|
|
168
171
|
if (!isLunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
|
|
169
172
|
let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
|
|
170
173
|
// "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
|
|
171
|
-
|
|
174
|
+
let m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
|
|
175
|
+
// "5일까지"처럼 일만 있으면: 오늘이 그날을 넘었으면 다음 달, 아니면 이번 달 (오늘 포함).
|
|
176
|
+
// 그 달에 없는 날("11월에 31일")은 아래에서 null
|
|
177
|
+
if (!hasYear && c.month === undefined && c.day !== undefined && c.monthOffset === undefined && c.nthWeek === undefined) {
|
|
178
|
+
if (c.day < ref.getDate())
|
|
179
|
+
m0 += 1;
|
|
180
|
+
y += Math.floor(m0 / 12);
|
|
181
|
+
m0 %= 12;
|
|
182
|
+
}
|
|
172
183
|
// 연도 미지정 + 이미 지났으면 내년으로.
|
|
173
184
|
// 월일("3월 1일")은 그 날이, 월("3월", "2월 말")은 그 달이 통째로 지났을 때.
|
|
174
185
|
if (!hasYear && c.month !== undefined) {
|
|
@@ -322,6 +333,9 @@ function resolve(group, opts) {
|
|
|
322
333
|
// "~까지", "~이내": 시작은 기준 시각, 끝은 해당 시점(시간 없으면 그날 23:59)
|
|
323
334
|
// "~내내": 시작은 그 기간의 시작, 끝은 기간의 끝
|
|
324
335
|
result.end = hasTime ? new Date(d) : periodEnd(d, c);
|
|
336
|
+
// "이번 주말 내내" → 토요일부터 일요일까지
|
|
337
|
+
if (c.whole && c.weekend && !hasTime && result.end.getDay() === 6)
|
|
338
|
+
result.end.setDate(result.end.getDate() + 1);
|
|
325
339
|
// 규칙 30: "어제까지"처럼 이미 지난 마감은 구간이 아니라 그 시점 (date = end)
|
|
326
340
|
if (c.isEnd)
|
|
327
341
|
result.date = result.end < ref ? new Date(result.end) : new Date(ref);
|
|
@@ -333,7 +347,7 @@ function periodEnd(d, c) {
|
|
|
333
347
|
let end = new Date(d);
|
|
334
348
|
if (c.weekOffset !== undefined && c.weekday === undefined)
|
|
335
349
|
end.setDate(end.getDate() + 6);
|
|
336
|
-
else if (c.monthOffset !== undefined && c.monthPart === undefined && c.day === undefined)
|
|
350
|
+
else if (c.monthOffset !== undefined && c.monthPart === undefined && c.day === undefined && !c.sameDay)
|
|
337
351
|
end = new Date(end.getFullYear(), end.getMonth(), lastDayOfMonth(end.getFullYear(), end.getMonth()));
|
|
338
352
|
else if ((c.year !== undefined || c.yearOffset !== undefined) && c.month === undefined && !c.sameDay)
|
|
339
353
|
end = new Date(end.getFullYear(), 11, 31);
|
|
@@ -401,13 +415,25 @@ function resolveRecurrence(group, c, opts) {
|
|
|
401
415
|
}
|
|
402
416
|
if (!d)
|
|
403
417
|
return null;
|
|
418
|
+
// 규칙 34: "10주 동안 매주 월요일" → 오늘(또는 "A부터"의 A)부터 그 기간의 마지막 날 23:59까지
|
|
419
|
+
const rule = { ...r };
|
|
420
|
+
if (c.durationDays !== undefined || c.durationMonths !== undefined) {
|
|
421
|
+
// N달 뒤 같은 날의 전날까지. 그 달에 없는 날이면 말일로 맞춘다 (11/30 + 3달 → 2/28의 전날)
|
|
422
|
+
const until = sameDayIn(today.getFullYear(), today.getMonth() + (c.durationMonths ?? 0), today.getDate());
|
|
423
|
+
until.setDate(until.getDate() + (c.durationDays ?? 0) - 1);
|
|
424
|
+
until.setHours(23, 59, 0, 0);
|
|
425
|
+
if (until < d)
|
|
426
|
+
return null;
|
|
427
|
+
rule.until = until;
|
|
428
|
+
}
|
|
404
429
|
const last = group[group.length - 1];
|
|
430
|
+
const timed = hasTime || r.freq === 'HOURLY' || r.freq === 'MINUTELY';
|
|
405
431
|
const result = {
|
|
406
432
|
date: d,
|
|
407
433
|
text: '',
|
|
408
434
|
index: group[0].index,
|
|
409
|
-
hasTime:
|
|
410
|
-
recurrence: { ...
|
|
435
|
+
hasTime: timed,
|
|
436
|
+
recurrence: { ...rule, rrule: (0, recurrence_js_1.toRRule)(rule, !timed) },
|
|
411
437
|
};
|
|
412
438
|
result._end = last.index + last.text.length;
|
|
413
439
|
return result;
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -88,7 +88,15 @@ export interface Recurrence {
|
|
|
88
88
|
byMonthDay?: number;
|
|
89
89
|
/** 매년 몇 월 */
|
|
90
90
|
byMonth?: number;
|
|
91
|
-
/**
|
|
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/dist/ics.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ParseResult } from './types.js';
|
|
2
|
+
import type { ParsedEvent } from './event.js';
|
|
3
|
+
export interface ICSOptions {
|
|
4
|
+
/** 일정 제목. 기본값: parseEvent 결과면 그 title, parse 결과면 문장에서 찾은 부분 */
|
|
5
|
+
title?: string;
|
|
6
|
+
/**
|
|
7
|
+
* 시각을 이 시간대의 벽시계로 쓴다 (`DTSTART;TZID=Asia/Seoul:…`). 서머타임이 있는 곳의 반복 일정은 이걸 넘겨야
|
|
8
|
+
* 시각이 유지된다. 없으면 UTC로 쓴다. 종일 일정의 날짜도 이 시간대 기준.
|
|
9
|
+
*/
|
|
10
|
+
timezone?: string;
|
|
11
|
+
/** 시각은 있는데 끝이 없을 때 일정 길이(분). 기본값: 60 */
|
|
12
|
+
durationMinutes?: number;
|
|
13
|
+
/** VEVENT의 UID. 기본값: 무작위 */
|
|
14
|
+
uid?: string;
|
|
15
|
+
/** DTSTAMP. 기본값: new Date() */
|
|
16
|
+
now?: Date;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* parse / parseEvent 결과를 iCalendar(.ics) 문자열로 바꾼다. 날짜가 없으면 null.
|
|
20
|
+
* Google·Apple·Outlook 캘린더에서 그대로 가져올 수 있다.
|
|
21
|
+
*
|
|
22
|
+
* - 시각이 있으면 DTSTART/DTEND (끝이 없으면 durationMinutes 뒤)
|
|
23
|
+
* - 시각이 없으면 종일 일정 (`VALUE=DATE`, DTEND는 마지막 날의 다음날)
|
|
24
|
+
* - 반복이면 RRULE. UNTIL은 DTSTART 형식에 맞춰 UTC 또는 날짜로 다시 쓴다
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const ics = toICS(parseEvent('다음주 월요일 9시 팀 회의'), { timezone: 'Asia/Seoul' });
|
|
28
|
+
* // BEGIN:VCALENDAR … SUMMARY:팀 회의 … DTSTART;TZID=Asia/Seoul:20261102T090000 …
|
|
29
|
+
*/
|
|
30
|
+
export declare function toICS(input: ParseResult | ParsedEvent, options?: ICSOptions): string | null;
|
package/dist/ics.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { toRRule } from './parsers/recurrence.js';
|
|
2
|
+
import { toWallClock } from './util/timezone.js';
|
|
3
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
4
|
+
const ymd = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
|
5
|
+
const hms = (d) => `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
6
|
+
const utc = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T` +
|
|
7
|
+
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
|
8
|
+
/** TEXT 값 이스케이프 (RFC 5545 3.3.11) */
|
|
9
|
+
const escapeText = (s) => s.replace(/[\\;,]/g, (c) => `\\${c}`).replace(/\r?\n/g, '\\n');
|
|
10
|
+
/** 75바이트가 넘는 줄은 CRLF + 공백으로 접는다 (RFC 5545 3.1). UTF-8 글자 중간에서는 끊지 않는다 */
|
|
11
|
+
function fold(line) {
|
|
12
|
+
const out = [];
|
|
13
|
+
let cur = '';
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
for (const ch of line) {
|
|
16
|
+
const cp = ch.codePointAt(0);
|
|
17
|
+
const len = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
18
|
+
// 이어지는 줄은 앞의 공백 1바이트를 뺀 74바이트까지
|
|
19
|
+
if (bytes + len > (out.length ? 74 : 75)) {
|
|
20
|
+
out.push(cur);
|
|
21
|
+
cur = '';
|
|
22
|
+
bytes = 0;
|
|
23
|
+
}
|
|
24
|
+
cur += ch;
|
|
25
|
+
bytes += len;
|
|
26
|
+
}
|
|
27
|
+
out.push(cur);
|
|
28
|
+
return out.join('\r\n ');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* parse / parseEvent 결과를 iCalendar(.ics) 문자열로 바꾼다. 날짜가 없으면 null.
|
|
32
|
+
* Google·Apple·Outlook 캘린더에서 그대로 가져올 수 있다.
|
|
33
|
+
*
|
|
34
|
+
* - 시각이 있으면 DTSTART/DTEND (끝이 없으면 durationMinutes 뒤)
|
|
35
|
+
* - 시각이 없으면 종일 일정 (`VALUE=DATE`, DTEND는 마지막 날의 다음날)
|
|
36
|
+
* - 반복이면 RRULE. UNTIL은 DTSTART 형식에 맞춰 UTC 또는 날짜로 다시 쓴다
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const ics = toICS(parseEvent('다음주 월요일 9시 팀 회의'), { timezone: 'Asia/Seoul' });
|
|
40
|
+
* // BEGIN:VCALENDAR … SUMMARY:팀 회의 … DTSTART;TZID=Asia/Seoul:20261102T090000 …
|
|
41
|
+
*/
|
|
42
|
+
export function toICS(input, options = {}) {
|
|
43
|
+
if (!input.date)
|
|
44
|
+
return null;
|
|
45
|
+
const tz = options.timezone;
|
|
46
|
+
const wall = (d) => (tz ? toWallClock(d, tz) : d);
|
|
47
|
+
const title = options.title ?? ('title' in input ? input.title : input.text);
|
|
48
|
+
const lines = [
|
|
49
|
+
'BEGIN:VCALENDAR',
|
|
50
|
+
'VERSION:2.0',
|
|
51
|
+
'PRODID:-//ko-chrono//KO',
|
|
52
|
+
'CALSCALE:GREGORIAN',
|
|
53
|
+
'BEGIN:VEVENT',
|
|
54
|
+
`UID:${options.uid ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}@ko-chrono`}`,
|
|
55
|
+
`DTSTAMP:${utc(options.now ?? new Date())}`,
|
|
56
|
+
`SUMMARY:${escapeText(title)}`,
|
|
57
|
+
];
|
|
58
|
+
if (input.hasTime) {
|
|
59
|
+
const end = input.end ?? new Date(input.date.getTime() + (options.durationMinutes ?? 60) * 60_000);
|
|
60
|
+
const at = (name, d) => (tz ? `${name};TZID=${tz}:${ymd(wall(d))}T${hms(wall(d))}` : `${name}:${utc(d)}`);
|
|
61
|
+
lines.push(at('DTSTART', input.date), at('DTEND', end));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const first = wall(input.date);
|
|
65
|
+
const last = input.end ? wall(input.end) : first;
|
|
66
|
+
const after = new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1);
|
|
67
|
+
lines.push(`DTSTART;VALUE=DATE:${ymd(first)}`, `DTEND;VALUE=DATE:${ymd(after)}`);
|
|
68
|
+
}
|
|
69
|
+
const r = input.recurrence;
|
|
70
|
+
if (r) {
|
|
71
|
+
// recurrence.rrule의 UNTIL은 벽시계(floating) 형식이라, 시각이 있는 일정이면 UTC로 바꿔 쓴다
|
|
72
|
+
const rrule = r.until && input.hasTime ? toRRule({ ...r, until: undefined }) + `;UNTIL=${utc(r.until)}` : r.rrule;
|
|
73
|
+
lines.push(`RRULE:${rrule}`);
|
|
74
|
+
}
|
|
75
|
+
lines.push('END:VEVENT', 'END:VCALENDAR', '');
|
|
76
|
+
return lines.map(fold).join('\r\n');
|
|
77
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ParseOptions, ParseResult } from './types.js';
|
|
|
2
2
|
import { type ParsedEvent } from './event.js';
|
|
3
3
|
export type { Ambiguity, Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
|
|
4
4
|
export type { ParsedEvent } from './event.js';
|
|
5
|
+
export { toICS, type ICSOptions } from './ics.js';
|
|
5
6
|
/**
|
|
6
7
|
* 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
|
|
7
8
|
*
|
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
|
|
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;
|