cron-explain-ja
Version:
cron式と日本語を相互変換するライブラリ / CLI
2,513 lines • 79.9 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region src/cron/fields.ts
const MONTH_NAMES = {
JAN: 1,
FEB: 2,
MAR: 3,
APR: 4,
MAY: 5,
JUN: 6,
JUL: 7,
AUG: 8,
SEP: 9,
OCT: 10,
NOV: 11,
DEC: 12
};
const DOW_NAMES = {
SUN: 0,
MON: 1,
TUE: 2,
WED: 3,
THU: 4,
FRI: 5,
SAT: 6
};
const SECOND_SPEC = {
field: "second",
label: "秒",
min: 0,
max: 59,
inputMax: 59
};
const MINUTE_SPEC = {
field: "minute",
label: "分",
min: 0,
max: 59,
inputMax: 59
};
const HOUR_SPEC = {
field: "hour",
label: "時",
min: 0,
max: 23,
inputMax: 23
};
const DOM_SPEC = {
field: "dayOfMonth",
label: "日",
min: 1,
max: 31,
inputMax: 31
};
const MONTH_SPEC = {
field: "month",
label: "月",
min: 1,
max: 12,
inputMax: 12,
names: MONTH_NAMES
};
const DOW_SPEC = {
field: "dayOfWeek",
label: "曜日",
min: 0,
max: 6,
inputMax: 7,
names: DOW_NAMES
};
/** 5 フィールド式の並び */
const FIELD_SPECS = [
MINUTE_SPEC,
HOUR_SPEC,
DOM_SPEC,
MONTH_SPEC,
DOW_SPEC
];
/** 6 フィールド(秒付き)式の並び */
const FIELD_SPECS_WITH_SECONDS = [SECOND_SPEC, ...FIELD_SPECS];
const MACROS = {
"@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@midnight": "0 0 * * *",
"@hourly": "0 * * * *"
};
/**
* crontab では有効だが、実行タイミングが日時で決まらないため説明できない指定。
* 値はエラーメッセージに使う理由。
*/
const UNSCHEDULABLE_MACROS = { "@reboot": "起動時に一度だけ実行される指定で、日時を持たないため説明できません" };
const DOW_LABELS = [
"日曜日",
"月曜日",
"火曜日",
"水曜日",
"木曜日",
"金曜日",
"土曜日"
];
//#endregion
//#region src/errors.ts
/**
* cron 式の構文エラー。
*/
var CronSyntaxError = class extends Error {
field;
position;
constructor(message, options = {}) {
super(message);
this.name = "CronSyntaxError";
if (options.field !== void 0) this.field = options.field;
if (options.position !== void 0) this.position = options.position;
}
};
/**
* `parse({ strict: true })` で解釈が曖昧だったときに投げられる。
*/
var ParseAmbiguityError = class extends Error {
result;
constructor(message, result) {
super(message);
this.name = "ParseAmbiguityError";
this.result = result;
}
};
/**
* タイムゾーン名を解釈できなかったときに投げられる。
*/
var CronTimeZoneError = class extends Error {
/** 与えられたゾーン名 */
timeZone;
constructor(message, timeZone) {
super(message);
this.name = "CronTimeZoneError";
this.timeZone = timeZone;
}
};
//#endregion
//#region src/cron/parser.ts
/** カンマ区切りで位置情報を保ったまま分割する */
function splitParts(text, offset, separator) {
const parts = [];
let start = 0;
for (let i = 0; i <= text.length; i++) if (i === text.length || text[i] === separator) {
parts.push({
text: text.slice(start, i),
offset: offset + start
});
start = i + 1;
}
return parts;
}
/** 曜日の 7 は日曜(0)と同じ値を指す */
function normalizeWeekday(value, spec) {
return spec.field === "dayOfWeek" && value === 7 ? 0 : value;
}
/** 書かれたままの値を読む。曜日の 7 は 0 に畳まない */
function parseNumber(text, spec, position) {
const upper = text.toUpperCase();
const named = spec.names?.[upper];
if (named !== void 0) return named;
if (!/^\d{1,2}$/.test(text)) throw new CronSyntaxError(`${spec.label} フィールドの値 '${text}' を解釈できません`, {
field: spec.field,
position
});
const value = Number(text);
if (value < spec.min || value > spec.inputMax) throw new CronSyntaxError(`${spec.label} フィールドの値 ${value} は範囲外です (${spec.min}-${spec.inputMax})`, {
field: spec.field,
position
});
return value;
}
/** 値を読んで、曜日の 7 を 0 に正規化する */
function parseValue(text, spec, position) {
return normalizeWeekday(parseNumber(text, spec, position), spec);
}
function parseAtom(text, spec, position) {
if (text === "") throw new CronSyntaxError(`${spec.label} フィールドが空です`, {
field: spec.field,
position
});
if (text === "*") return { kind: "any" };
if (text === "?") {
if (spec.field !== "dayOfMonth" && spec.field !== "dayOfWeek") throw new CronSyntaxError(`'?' は日・曜日フィールドでのみ使用できます`, {
field: spec.field,
position
});
return { kind: "noSpecific" };
}
if (spec.field === "dayOfMonth") {
if (text === "L") return { kind: "last" };
const lastOffset = /^L-(\d{1,2})$/.exec(text);
if (lastOffset?.[1] !== void 0) {
const offset = Number(lastOffset[1]);
if (offset > 30) throw new CronSyntaxError(`'L-${offset}' のオフセットは 0-30 の範囲で指定してください`, {
field: spec.field,
position
});
return {
kind: "last",
offset
};
}
const nearest = /^(\d{1,2})W$/i.exec(text);
if (nearest?.[1] !== void 0) return {
kind: "nearestWeekday",
day: parseValue(nearest[1], spec, position)
};
}
if (spec.field === "dayOfWeek") {
const hash = text.indexOf("#");
if (hash >= 0) {
const weekday = parseValue(text.slice(0, hash), spec, position);
const nthText = text.slice(hash + 1);
if (!/^[1-5]$/.test(nthText)) throw new CronSyntaxError(`'#' の後は 1-5 で指定してください`, {
field: spec.field,
position: position + hash + 1
});
return {
kind: "nth",
weekday,
nth: Number(nthText)
};
}
if (/^.+L$/i.test(text)) return {
kind: "nth",
weekday: parseValue(text.slice(0, -1), spec, position),
nth: -1
};
if (text.toUpperCase() === "L") throw new CronSyntaxError(`曜日フィールドの 'L' は '5L' のように曜日と組み合わせてください`, {
field: spec.field,
position
});
}
const dash = text.indexOf("-");
if (dash > 0) {
const from = parseNumber(text.slice(0, dash), spec, position);
const to = parseNumber(text.slice(dash + 1), spec, position + dash + 1);
if (spec.field === "dayOfWeek" && from === spec.min && to === spec.inputMax) return {
kind: "range",
from: spec.min,
to: spec.max
};
return {
kind: "range",
from: normalizeWeekday(from, spec),
to: normalizeWeekday(to, spec)
};
}
return {
kind: "value",
value: parseValue(text, spec, position)
};
}
function parseItem(text, spec, position) {
const slash = text.indexOf("/");
if (slash < 0) return parseAtom(text, spec, position);
const stepText = text.slice(slash + 1);
if (!/^\d{1,2}$/.test(stepText)) throw new CronSyntaxError(`${spec.label} フィールドの刻み幅 '${stepText}' を解釈できません`, {
field: spec.field,
position: position + slash + 1
});
const step = Number(stepText);
if (step < 1) throw new CronSyntaxError(`${spec.label} フィールドの刻み幅は 1 以上で指定してください`, {
field: spec.field,
position: position + slash + 1
});
const baseText = text.slice(0, slash);
const base = parseAtom(baseText === "" ? "*" : baseText, spec, position);
if (base.kind === "value") return {
kind: "step",
base: parseAtom(`${baseText}-${spec.inputMax}`, spec, position),
step
};
if (base.kind !== "any" && base.kind !== "range") throw new CronSyntaxError(`${spec.label} フィールドの刻みは '*' か範囲にのみ指定できます`, {
field: spec.field,
position
});
return {
kind: "step",
base,
step
};
}
function parseField(text, spec, offset) {
const items = splitParts(text, offset, ",").map((part) => parseItem(part.text, spec, part.offset));
const first = items[0];
if (first === void 0) throw new CronSyntaxError(`${spec.label} フィールドが空です`, {
field: spec.field,
position: offset
});
if (items.length === 1) return first;
return {
kind: "list",
items
};
}
function collectExtensions(ast, into) {
switch (ast.kind) {
case "last":
into.add("L");
break;
case "nth":
into.add(ast.nth === -1 ? "L" : "#");
break;
case "nearestWeekday":
into.add("W");
break;
case "noSpecific":
into.add("?");
break;
case "list":
for (const item of ast.items) collectExtensions(item, into);
break;
case "step": collectExtensions(ast.base, into);
}
}
/**
* cron 式を構文解析する。不正な式は {@link CronSyntaxError} を投げる。
*/
function parseExpression(expression, options = {}) {
if (typeof expression !== "string") throw new CronSyntaxError("cron 式は文字列で指定してください");
const trimmed = expression.trim();
if (trimmed === "") throw new CronSyntaxError("cron 式が空です");
let source = trimmed;
let macro;
if (source.startsWith("@")) {
const token = /^\S+/.exec(source)?.[0] ?? source;
const lower = token.toLowerCase();
const expanded = MACROS[lower];
if (expanded === void 0) {
const reason = UNSCHEDULABLE_MACROS[lower];
if (reason !== void 0) throw new CronSyntaxError(`'${token}' は${reason}`, { position: 0 });
throw new CronSyntaxError(`マクロ '${token}' には対応していません`, { position: 0 });
}
macro = token;
source = expanded;
}
const specs = options.seconds === true ? FIELD_SPECS_WITH_SECONDS : FIELD_SPECS;
const fields = [];
const re = /\S+/g;
let match = re.exec(source);
while (match !== null) {
fields.push({
text: match[0],
offset: match.index
});
match = re.exec(source);
}
if (fields.length !== specs.length) {
const hint = fields.length === 6 && options.seconds !== true ? "。秒付きの式は seconds オプションを有効にしてください" : "";
throw new CronSyntaxError(`フィールド数が不正です(${specs.length} 個必要ですが ${fields.length} 個でした)${hint}`, { position: 0 });
}
const parsed = specs.map((spec, index) => {
const part = fields[index];
/* c8 ignore next -- fields.length === specs.length を確認済み */
if (part === void 0) throw new CronSyntaxError("フィールド数が不正です");
return {
spec,
part,
ast: parseField(part.text, spec, part.offset)
};
});
const byField = new Map(parsed.map((entry) => [entry.spec.field, entry]));
const get = (field) => {
const entry = byField.get(field);
/* c8 ignore next -- specs は必ず全フィールドを含む */
if (entry === void 0) throw new CronSyntaxError("フィールドが不足しています");
return entry;
};
const minute = get("minute");
const hour = get("hour");
const dayOfMonth = get("dayOfMonth");
const month = get("month");
const dayOfWeek = get("dayOfWeek");
const seconds = options.seconds === true ? get("second") : void 0;
const ast = {
minute: minute.ast,
hour: hour.ast,
dayOfMonth: dayOfMonth.ast,
month: month.ast,
dayOfWeek: dayOfWeek.ast
};
if (seconds !== void 0) ast.seconds = seconds.ast;
const extensions = /* @__PURE__ */ new Set();
for (const entry of parsed) collectExtensions(entry.ast, extensions);
const result = {
ast,
raw: {
minute: minute.part.text,
hour: hour.part.text,
dayOfMonth: dayOfMonth.part.text,
month: month.part.text,
dayOfWeek: dayOfWeek.part.text
},
extensions: [...extensions]
};
if (seconds !== void 0) result.raw.seconds = seconds.part.text;
if (macro !== void 0) result.macro = macro;
return result;
}
//#endregion
//#region src/cron/values.ts
/** 値に展開できない拡張構文(L / # / W)を含むか。`?` は制約なしなので含めない */
function hasExtension(ast) {
switch (ast.kind) {
case "last":
case "nth":
case "nearestWeekday": return true;
case "list": return ast.items.some(hasExtension);
case "step": return hasExtension(ast.base);
default: return false;
}
}
/** `*` あるいは `?`(=制約なし)か */
function isAny(ast) {
if (ast.kind === "any" || ast.kind === "noSpecific") return true;
if (ast.kind === "step") return ast.step === 1 && isAny(ast.base);
return false;
}
/** 表記は違っても、そのフィールドの全値を含むか(`0-6` など) */
function coversAll(ast, spec) {
if (isAny(ast)) return true;
if (hasExtension(ast)) return false;
return expandField(ast, spec).length === spec.max - spec.min + 1;
}
/**
* フィールドの全域に対する刻みなら、その刻み幅を返す。
* `0-59/5` のように範囲が全域を覆う場合を含み、`*` を base にしたものと同じ意味になる。
*/
function fullRangeStep(ast, spec) {
if (ast.kind !== "step") return void 0;
const base = ast.base;
if (!(isAny(base) || base.kind === "range" && base.from === spec.min && base.to === spec.max)) return void 0;
if (ast.step > spec.max - spec.min) return void 0;
return ast.step;
}
/**
* 「AからBまでNごと」と説明できる刻みなら、その範囲と刻み幅を返す。
* 幅を超える刻みは起点で 1 回動くだけなので、範囲としては説明しない。
*/
function rangeStep(ast, spec) {
if (ast.kind !== "step" || ast.base.kind !== "range") return void 0;
const { from, to } = ast.base;
const span = spec.max - spec.min + 1;
const width = from <= to ? to - from : span - from + to;
if (ast.step > width) return void 0;
return {
from,
to,
step: ast.step
};
}
function rangeValues(from, to, spec, step) {
const values = [];
const span = spec.max - spec.min + 1;
const length = from <= to ? to - from + 1 : span - from + to + 1;
for (let i = 0; i < length; i += step) values.push(spec.min + (from - spec.min + i) % span);
return values;
}
/**
* フィールドの構文木を実際の値へ展開する。
* 拡張構文(L / # / W)は静的に展開できないため空配列を返す。
*/
function expandField(ast, spec) {
const values = collect$1(ast, spec);
return [...new Set(values)].sort((a, b) => a - b);
}
function collect$1(ast, spec) {
switch (ast.kind) {
case "any":
case "noSpecific": return rangeValues(spec.min, spec.max, spec, 1);
case "value": return [ast.value];
case "range": return rangeValues(ast.from, ast.to, spec, 1);
case "step": {
const base = ast.base;
if (base.kind === "range") return rangeValues(base.from, base.to, spec, ast.step);
return rangeValues(spec.min, spec.max, spec, ast.step);
}
case "list": return ast.items.flatMap((item) => collect$1(item, spec));
default: return [];
}
}
/** 構文木を正規化された cron 表記へ戻す */
function formatField(ast) {
switch (ast.kind) {
case "any": return "*";
case "noSpecific": return "?";
case "value": return String(ast.value);
case "range": return `${ast.from}-${ast.to}`;
case "step": return `${formatField(ast.base)}/${ast.step}`;
case "list": return ast.items.map(formatField).join(",");
case "last": return ast.offset === void 0 ? "L" : `L-${ast.offset}`;
case "nth": return ast.nth === -1 ? `${ast.weekday}L` : `${ast.weekday}#${ast.nth}`;
case "nearestWeekday": return `${ast.day}W`;
}
}
/** 連続した値の並びを範囲に畳む([1,2,3,5] → [[1,3],[5,5]]) */
function toRanges(values) {
const ranges = [];
for (const value of values) {
const last = ranges[ranges.length - 1];
if (last !== void 0 && value === last[1] + 1) last[1] = value;
else ranges.push([value, value]);
}
return ranges;
}
/** 構文木を正規化された cron 式へ戻す */
function formatExpression(ast) {
const fields = [
ast.minute,
ast.hour,
ast.dayOfMonth,
ast.month,
ast.dayOfWeek
].map(formatField);
if (ast.seconds !== void 0) fields.unshift(formatField(ast.seconds));
return fields.join(" ");
}
//#endregion
//#region src/cron/next.ts
/** 瞬間 → UTC の壁時計 */
function partsOf(date) {
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
second: date.getUTCSeconds(),
dayOfWeek: date.getUTCDay()
};
}
/** UTC の壁時計 → 瞬間。範囲外の値(13 月・32 日・24 時など)は繰り上がる */
function dateOf(year, month, day, hour, minute, second) {
return new Date(Date.UTC(year, month - 1, day, hour, minute, second, 0));
}
/** sorted の中で value 以上の最小値。無ければ null */
function atLeast(sorted, value) {
for (const candidate of sorted) if (candidate >= value) return candidate;
return null;
}
/** 5 年先まで探索する */
const SEARCH_YEARS = 5;
/** 無限ループを避けるための保険 */
const MAX_ITERATIONS = 2e6;
/**
* cron 式の次回実行日時を求める。
*
* cron 式は UTC のサーバーで動くものとして解釈する。返るのは絶対時刻なので、
* どのタイムゾーンで表示するかは呼び出し側の裁量。
*
* `L` / `#` / `W` を含む式は v1 では計算対象外で、空配列を返す。
*/
function next(expression, options = {}) {
const parsed = parseExpression(expression, options.seconds === true ? { seconds: true } : {});
if (parsed.extensions.some((extension) => extension !== "?")) return [];
const count = options.count ?? 3;
if (count <= 0) return [];
const from = options.from ?? /* @__PURE__ */ new Date();
if (Number.isNaN(from.getTime())) return [];
const ast = parsed.ast;
const seconds = ast.seconds === void 0 ? [0] : expandField(ast.seconds, SECOND_SPEC);
const minutes = expandField(ast.minute, MINUTE_SPEC);
const hours = expandField(ast.hour, HOUR_SPEC);
const months = expandField(ast.month, MONTH_SPEC);
const days = expandField(ast.dayOfMonth, DOM_SPEC);
const weekdays = expandField(ast.dayOfWeek, DOW_SPEC);
if ([
seconds,
minutes,
hours,
months
].some((values) => values.length === 0)) return [];
const domRestricted = !isAny(ast.dayOfMonth);
const dowRestricted = !isAny(ast.dayOfWeek);
const daySet = new Set(days);
const weekdaySet = new Set(weekdays);
const monthSet = new Set(months);
const dayMatches = (parts) => {
if (domRestricted && dowRestricted) return daySet.has(parts.day) || weekdaySet.has(parts.dayOfWeek);
if (domRestricted) return daySet.has(parts.day);
if (dowRestricted) return weekdaySet.has(parts.dayOfWeek);
return true;
};
const limitYear = partsOf(from).year + SEARCH_YEARS;
const initial = partsOf(new Date(from.getTime() + 1e3));
let cursor = dateOf(initial.year, initial.month, initial.day, initial.hour, initial.minute, initial.second);
const results = [];
let iterations = 0;
while (results.length < count && iterations < MAX_ITERATIONS) {
iterations += 1;
const parts = partsOf(cursor);
if (parts.year > limitYear) break;
if (!monthSet.has(parts.month)) {
cursor = dateOf(parts.year, parts.month + 1, 1, 0, 0, 0);
continue;
}
if (!dayMatches(parts)) {
cursor = dateOf(parts.year, parts.month, parts.day + 1, 0, 0, 0);
continue;
}
const hour = atLeast(hours, parts.hour);
if (hour === null) {
cursor = dateOf(parts.year, parts.month, parts.day + 1, 0, 0, 0);
continue;
}
if (hour !== parts.hour) {
cursor = dateOf(parts.year, parts.month, parts.day, hour, 0, 0);
continue;
}
const minute = atLeast(minutes, parts.minute);
if (minute === null) {
cursor = dateOf(parts.year, parts.month, parts.day, parts.hour + 1, 0, 0);
continue;
}
if (minute !== parts.minute) {
cursor = dateOf(parts.year, parts.month, parts.day, parts.hour, minute, 0);
continue;
}
const second = atLeast(seconds, parts.second);
if (second === null) {
cursor = dateOf(parts.year, parts.month, parts.day, parts.hour, parts.minute + 1, 0);
continue;
}
if (second !== parts.second) {
cursor = dateOf(parts.year, parts.month, parts.day, parts.hour, parts.minute, second);
continue;
}
results.push(new Date(cursor.getTime()));
cursor = new Date(cursor.getTime() + 1e3);
}
return results;
}
//#endregion
//#region src/cron/zone.ts
/**
* 自然言語(日本語)側の既定のタイムゾーン。
*
* cron 式は常に UTC のサーバーで動くものとして扱い、日本語はこのゾーンの壁時計として読む。
*/
const DEFAULT_TIME_ZONE = "Asia/Tokyo";
/** Intl.DateTimeFormat の生成は重いのでゾーンごとに使い回す */
const formatters = /* @__PURE__ */ new Map();
function formatter(timeZone) {
const cached = formatters.get(timeZone);
if (cached !== void 0) return cached;
const created = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
formatters.set(timeZone, created);
return created;
}
/**
* `'local'` を実行環境のゾーン名に、未指定を {@link DEFAULT_TIME_ZONE} に解決し、
* IANA の正規名に揃えて返す。
*
* @throws {CronTimeZoneError} 実行環境が知らないゾーン名の場合
*/
function resolveTimeZone(tz) {
if (tz === void 0) return DEFAULT_TIME_ZONE;
const requested = tz === "local" ? new Intl.DateTimeFormat().resolvedOptions().timeZone : tz;
try {
return new Intl.DateTimeFormat("en-US", { timeZone: requested }).resolvedOptions().timeZone;
} catch {
throw new CronTimeZoneError(`タイムゾーン '${tz}' を解釈できません`, tz);
}
}
/** 壁時計を UTC とみなしたミリ秒 */
function asUtc(wall) {
return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
}
/** その瞬間の壁時計と、そのときのゾーンオフセット(分) */
function wallClockWithOffset(timeZone, date) {
const found = {};
for (const part of formatter(timeZone).formatToParts(date)) if (part.type !== "literal") found[part.type] = part.value;
const year = Number(found.year);
const month = Number(found.month);
const day = Number(found.day);
const wall = {
year,
month,
day,
hour: Number(found.hour) % 24,
minute: Number(found.minute),
second: Number(found.second),
dayOfWeek: new Date(Date.UTC(year, month - 1, day)).getUTCDay()
};
return {
wall,
offset: (asUtc(wall) - Math.floor(date.getTime() / 1e3) * 1e3) / 6e4
};
}
/** その瞬間のゾーンオフセット(分)。UTC より東が正 */
function offsetMinutes(timeZone, date) {
return wallClockWithOffset(timeZone, date).offset;
}
const fixedOffsets = /* @__PURE__ */ new Map();
/**
* 変わらないゾーンオフセット(分)を返す。
*
* cron 式の書き換えは固定のオフセットでしか行えないため、オフセットが動くゾーンは
* ここで弾く。書き換えた式は crontab に貼られたあと何年も動き続けるので、
* 今年だけでなく**翌年まで**を 1 か月ごとにサンプルして、すべて同じであることを確かめる
* (夏時間を来年から始める国を今年のぶんだけ見て通すと、半年ずれた式を黙って出すことになる)。
*
* @throws {CronTimeZoneError} 期間内でオフセットが変わる場合
*/
function fixedOffsetMinutes(timeZone, reference = /* @__PURE__ */ new Date()) {
const year = reference.getUTCFullYear();
const key = `${timeZone}@${year}`;
const cached = fixedOffsets.get(key);
if (cached !== void 0) return cached;
const offsets = /* @__PURE__ */ new Set();
for (let month = 0; month < 24; month += 1) offsets.add(offsetMinutes(timeZone, new Date(Date.UTC(year, month, 15, 12))));
const [offset] = [...offsets];
if (offsets.size > 1 || offset === void 0) throw new CronTimeZoneError(`${timeZone} は ${year}-${year + 1} 年にオフセットが変わる(夏時間など)ため、cron 式を固定のオフセットに書き換えられません`, timeZone);
fixedOffsets.set(key, offset);
return offset;
}
//#endregion
//#region src/cron/shift.ts
const MINUTES_PER_DAY = 1440;
/** 日をまたぐ書き換えが月の長さに左右されないのは 1-28 日の範囲だけ */
const SAFE_DAY_MIN = 1;
const SAFE_DAY_MAX = 28;
function mod(value, size) {
return (value % size + size) % size;
}
function sorted(values) {
return [...new Set(values)].sort((a, b) => a - b);
}
function sameValues(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/** 「+9:00」「-4:30」 */
function formatOffset(minutes) {
const absolute = Math.abs(minutes);
const hour = Math.floor(absolute / 60);
const minute = absolute % 60;
return `${minutes < 0 ? "-" : "+"}${hour}:${String(minute).padStart(2, "0")}`;
}
/**
* 値の並びから、なるべく自然な構文木を組み立てる。
*
* 連番は範囲に、等間隔は刻みに畳む。説明文の質がここで決まる
* (`1,3,5,…,23` を並べ立てず `1-23/2` にする)。
*/
function fromValues(values, spec) {
const first = values[0];
const last = values[values.length - 1];
/* c8 ignore next -- 呼び出し側が空配列を渡さない */
if (first === void 0 || last === void 0) return { kind: "any" };
if (values.length === spec.max - spec.min + 1) return { kind: "any" };
if (values.length === 1) return {
kind: "value",
value: first
};
const step = values[1] - first;
if (values.length > 2 && step > 1 && values.every((value, index) => index === 0 || value - values[index - 1] === step)) return {
kind: "step",
base: first === spec.min && last + step > spec.max ? { kind: "any" } : {
kind: "range",
from: first,
to: last
},
step
};
const items = toRanges(values).map(([from, to]) => from === to ? {
kind: "value",
value: from
} : {
kind: "range",
from,
to
});
return items.length === 1 ? items[0] : {
kind: "list",
items
};
}
/**
* cron 式(UTC) と `timeZone` の壁時計の間で、フィールドをずらす。
*
* cron 式は「フィールドごとに独立した値の集合」しか表せないので、ずらした結果が
* その形に収まらない場合は書き換えられない。収まらないまま近い式を返すと、
* 半年ずれた予定を黙って出すことになるため {@link CronTimeZoneError} で失敗させる。
*
* @throws {CronTimeZoneError} ゾーンのオフセットが動く、または結果が cron 式で表せない場合
*/
function shiftAst(ast, timeZone, direction) {
if (timeZone === "UTC") return ast;
const zoneOffset = fixedOffsetMinutes(timeZone);
const deltaMinutes = direction === "toLocal" ? zoneOffset : -zoneOffset;
if (deltaMinutes === 0) return ast;
const offset = formatOffset(zoneOffset);
const fail = (reason) => {
throw new CronTimeZoneError(`${timeZone}(時差 ${offset})では${reason}`, timeZone);
};
const minutes = expandField(ast.minute, MINUTE_SPEC);
const hours = expandField(ast.hour, HOUR_SPEC);
if (minutes.length === 0 || hours.length === 0) return fail("分・時のフィールドを展開できないため、cron 式に書き換えられません");
const moved = /* @__PURE__ */ new Set();
const carries = /* @__PURE__ */ new Set();
for (const hour of hours) for (const minute of minutes) {
const value = hour * 60 + minute + deltaMinutes;
moved.add(mod(value, MINUTES_PER_DAY));
carries.add(Math.floor(value / MINUTES_PER_DAY));
}
const newMinutes = sorted([...moved].map((value) => value % 60));
const newHours = sorted([...moved].map((value) => Math.floor(value / 60)));
if (newMinutes.length * newHours.length !== moved.size) return fail("分と時の組み合わせが崩れるため、cron 式に書き換えられません(分の繰り上がりが時刻によって変わります)");
const domRestricted = !isAny(ast.dayOfMonth);
const dowRestricted = !isAny(ast.dayOfWeek);
const monthRestricted = !coversAll(ast.month, MONTH_SPEC);
const everyDay = !domRestricted && !dowRestricted && !monthRestricted;
const [onlyCarry] = [...carries];
const dayShift = carries.size === 1 && onlyCarry !== void 0 ? onlyCarry : null;
if (dayShift === null && !everyDay) return fail("日付をまたぐ時刻とまたがない時刻が混ざるため、cron 式に書き換えられません");
let dayOfMonth = ast.dayOfMonth;
let dayOfWeek = ast.dayOfWeek;
if (dayShift !== null && dayShift !== 0 && !everyDay) {
if (hasExtension(ast.dayOfMonth) || hasExtension(ast.dayOfWeek)) return fail("日付が 1 日ずれるため、L / # / W を含む式は書き換えられません");
if (monthRestricted && (!domRestricted || dowRestricted)) return fail("日付が 1 日ずれて月をまたぐため、cron 式に書き換えられません");
if (domRestricted) {
const days = expandField(ast.dayOfMonth, DOM_SPEC);
const shiftedDays = days.map((day) => day + dayShift);
if (!days.every((day, index) => day <= SAFE_DAY_MAX && shiftedDays[index] >= SAFE_DAY_MIN && shiftedDays[index] <= SAFE_DAY_MAX)) return fail("日付が 1 日ずれて月をまたぐ可能性があるため、cron 式に書き換えられません(1-28 日の範囲に収まる指定であれば書き換えられます)");
dayOfMonth = fromValues(shiftedDays, DOM_SPEC);
}
if (dowRestricted) dayOfWeek = fromValues(sorted(expandField(ast.dayOfWeek, DOW_SPEC).map((day) => mod(day + dayShift, 7))), DOW_SPEC);
}
return {
...ast,
minute: sameValues(newMinutes, minutes) ? ast.minute : fromValues(newMinutes, MINUTE_SPEC),
hour: sameValues(newHours, hours) ? ast.hour : fromValues(newHours, HOUR_SPEC),
dayOfMonth,
dayOfWeek
};
}
/**
* cron 式の文字列を {@link shiftAst} と同じ規則でずらす。
*
* @throws {CronSyntaxError} 式が不正な場合
* @throws {CronTimeZoneError} ゾーンのオフセットが動く、または結果が cron 式で表せない場合
*/
function shiftExpression(expression, timeZone, direction, options = {}) {
if (timeZone === "UTC") return expression;
const { ast } = parseExpression(expression, options);
return formatExpression(shiftAst(ast, timeZone, direction));
}
//#endregion
//#region src/cron/validate.ts
const DAYS_IN_MONTH = [
31,
29,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31
];
const MONTH_LABELS = [
"1月",
"2月",
"3月",
"4月",
"5月",
"6月",
"7月",
"8月",
"9月",
"10月",
"11月",
"12月"
];
/**
* cron 式を検証する。構文エラーは throw せず {@link ValidationResult} として返す。
*/
function validate(expression, options = {}) {
const errors = [];
const warnings = [];
let parsed;
try {
parsed = parseExpression(expression, options);
} catch (error) {
if (error instanceof CronSyntaxError) {
const entry = {
field: error.field ?? "expression",
message: error.message
};
if (error.position !== void 0) entry.position = error.position;
errors.push(entry);
return {
valid: false,
errors,
warnings
};
}
/* c8 ignore next 2 -- parseExpression は CronSyntaxError しか投げない */
throw error;
}
const { ast, extensions } = parsed;
if (extensions.includes("L") || extensions.includes("#") || extensions.includes("W")) {
const used = extensions.filter((extension) => extension !== "?").join("', '");
warnings.push(`'${used}' は Quartz 拡張です。標準の cron(Vixie cron)では動作しません`);
warnings.push("拡張構文を含む式では次回実行日時(next)を計算できません");
}
if (!isAny(ast.dayOfMonth) && !isAny(ast.dayOfWeek)) warnings.push("標準の cron では日と曜日を同時に指定すると OR 条件になり、どちらかに一致する日に実行されます");
if (!isAny(ast.month) && !isAny(ast.dayOfMonth) && !hasExtension(ast.dayOfMonth) && !hasExtension(ast.month)) {
const months = expandField(ast.month, MONTH_SPEC);
const days = expandField(ast.dayOfMonth, DOM_SPEC);
const impossible = [];
let anyPossible = false;
for (const month of months) {
const limit = DAYS_IN_MONTH[month - 1] ?? 31;
for (const day of days) if (day <= limit) anyPossible = true;
else impossible.push(`${MONTH_LABELS[month - 1] ?? `${month}月`}${day}日`);
}
if (!anyPossible && impossible.length > 0) warnings.push(`${impossible.join("、")}は存在しないため、このジョブは実行されません`);
else if (impossible.length > 0) warnings.push(`${impossible.join("、")}は存在しないため、その日は実行されません`);
if (months.includes(2) && days.includes(29)) warnings.push("2月29日はうるう年にのみ実行されます");
}
return {
valid: true,
errors,
warnings
};
}
//#endregion
//#region src/explain/time.ts
function pad2(value) {
return value < 10 ? `0${value}` : String(value);
}
/**
* 「午前9時」「15時」のように時だけを表す。
*/
function formatHour(hour, style) {
if (style.hour === "24h") return `${hour}時`;
if (hour < 12) return `午前${hour}時`;
return `午後${hour - 12}時`;
}
/**
* 「午前9時」「午後6時30分」「正午」のように時刻を表す。
*/
function formatTime(hour, minute, style) {
if (style.style === "casual" && style.hour === "12h" && hour === 12 && minute === 0) return "正午";
const base = formatHour(hour, style);
if (style.style === "formal") return `${base}${pad2(minute)}分`;
return minute === 0 ? base : `${base}${minute}分`;
}
//#endregion
//#region src/explain/field.ts
/** 「AとB」「A、B、C」 */
function joinJa(items) {
if (items.length === 0) return "";
if (items.length === 1) return items[0] ?? "";
if (items.length === 2) return `${items[0]}と${items[1]}`;
return items.join("、");
}
/**
* 値の並びを、連続部分を範囲に畳みながら日本語にする。
*/
function describeValues(values, point, range) {
const texts = [];
for (const [from, to] of toRanges(values)) if (to - from >= 2) texts.push(range(from, to));
else for (let value = from; value <= to; value++) texts.push(point(value));
return joinJa(texts);
}
function describeMinuteValues(ast) {
return describeValues(expandField(ast, MINUTE_SPEC), (value) => `${value}分`, (from, to) => `${from}分から${to}分まで`);
}
/**
* 分フィールドを、文の組み立てに使える形に分解する。
* 文単位(`compose.ts`)とフィールド単位({@link describeMinuteField})で
* 同じ規則を使うため、判定はここだけに置く。
*/
function minutePart(ast) {
if (coversAll(ast, MINUTE_SPEC)) return { kind: "any" };
const step = fullRangeStep(ast, MINUTE_SPEC);
if (step !== void 0) return {
kind: "step",
text: `${step}分ごと`
};
const ranged = rangeStep(ast, MINUTE_SPEC);
if (ranged !== void 0) return {
kind: "rangeStep",
...ranged
};
const values = expandField(ast, MINUTE_SPEC);
const first = values[0];
if (values.length === 1 && first !== void 0) return {
kind: "single",
value: first
};
return {
kind: "values",
text: describeMinuteValues(ast)
};
}
/** 前に何も置かずに単独で読める分の表現 */
function minuteBare(part) {
switch (part.kind) {
case "any": return "毎分";
case "step": return part.text;
case "rangeStep": return `${part.from}分から${part.to}分まで${part.step}分ごと`;
case "single": return `${part.value}分`;
case "values": return part.text;
}
}
/**
* 時の節に続けるときの、時の中での分の表現。
* 刻みは時をまたがないので、上限が 59 分なら「59分まで」は何も足さない。
* 時の範囲の「まで」と重なって読みにくくするだけなので落とす。
*/
function minuteWithinHour(part) {
if (part.kind !== "rangeStep") return minuteBare(part);
const { from, to, step } = part;
if (to === MINUTE_SPEC.max) return `${from}分から${step}分ごと`;
return `${from}分から${to}分まで${step}分ごと`;
}
/** 分フィールドの説明。全域は「毎分」、ステップ指定は「N分ごと」 */
function describeMinuteField(ast) {
return minuteBare(minutePart(ast));
}
function describeSecondField(ast) {
if (coversAll(ast, SECOND_SPEC)) return "毎秒";
const step = fullRangeStep(ast, SECOND_SPEC);
if (step !== void 0) return `${step}秒ごと`;
return describeValues(expandField(ast, SECOND_SPEC), (value) => `${value}秒`, (from, to) => `${from}秒から${to}秒まで`);
}
function describeHourValues(ast, style) {
return describeValues(expandField(ast, HOUR_SPEC), (value) => formatHour(value, style), (from, to) => `${formatHour(from, style)}から${formatHour(to, style)}まで`);
}
function describeHourField(ast, style) {
if (coversAll(ast, HOUR_SPEC)) return "毎時";
const step = fullRangeStep(ast, HOUR_SPEC);
if (step !== void 0) return `${step}時間ごと`;
const ranged = rangeStep(ast, HOUR_SPEC);
if (ranged !== void 0) return `${formatHour(ranged.from, style)}から${formatHour(ranged.to, style)}まで${ranged.step}時間ごと`;
return describeHourValues(ast, style);
}
function describeMonthField(ast) {
if (coversAll(ast, MONTH_SPEC)) return "毎月";
const step = fullRangeStep(ast, MONTH_SPEC);
if (step !== void 0) return `${step}か月ごと`;
return describeValues(expandField(ast, MONTH_SPEC), (value) => `${value}月`, (from, to) => `${from}月から${to}月まで`);
}
function describeDayOfMonthField(ast) {
if (coversAll(ast, DOM_SPEC)) return "毎日";
if (ast.kind === "last") return ast.offset === void 0 ? "月末" : `月末の${ast.offset}日前`;
if (ast.kind === "nearestWeekday") return `${ast.day}日に最も近い平日`;
const step = fullRangeStep(ast, DOM_SPEC);
if (step !== void 0) return `${step}日ごと`;
if (ast.kind === "list" && hasExtension(ast)) return joinJa(ast.items.map(describeDayOfMonthField));
return describeValues(expandField(ast, DOM_SPEC), (value) => `${value}日`, (from, to) => `${from}日から${to}日まで`);
}
function dowLabel(value) {
return DOW_LABELS[value] ?? `${value}`;
}
function describeDayOfWeekField(ast, options) {
if (ast.kind === "any" || ast.kind === "noSpecific") return "毎日";
if (ast.kind === "nth") {
const label = dowLabel(ast.weekday);
return ast.nth === -1 ? `最終${label}` : `第${ast.nth}${label}`;
}
if (ast.kind === "list" && hasExtension(ast)) return joinJa(ast.items.map((item) => describeDayOfWeekField(item, {
...options,
weekly: false
})));
const values = expandField(ast, DOW_SPEC);
if (values.length === 7) return "毎日";
if (options.collapse) {
const key = values.join(",");
if (key === "1,2,3,4,5") return "平日";
if (key === "0,6") return "週末";
}
const text = describeValues(values, dowLabel, (from, to) => `${dowLabel(from)}から${dowLabel(to)}まで`);
return options.weekly ? `毎週${text}` : text;
}
//#endregion
//#region src/explain/compose.ts
/** 「毎分」「15分ごと」「毎時0分」のように、時を限定しない文での分の表現 */
function minuteAlone(part) {
switch (part.kind) {
case "any":
case "step":
case "rangeStep": return minuteBare(part);
case "single": return `毎時${part.value}分`;
case "values": return `毎時${part.text}`;
}
}
/**
* 時の節に続けるときの分の表現。
* 「AからBまでNごと」を自己完結した形のまま置くと、時の範囲の「まで」と重なって
* 「午前9時から午後5時まで5分から59分まで15分ごと」のように読めなくなる。
* 「毎時」で分の節の始まりを示し、時の中での位置として組み立てる。
*/
function minuteAfterHours(part) {
if (part.kind === "rangeStep") return `毎時${minuteWithinHour(part)}`;
return minuteAlone(part);
}
/**
* 時刻部分を組み立てる。
*/
function describeTime(ast, options) {
const minute = minutePart(ast.minute);
const hour = ast.hour;
let result;
const hourStep = fullRangeStep(hour, HOUR_SPEC);
const hourRange = rangeStep(hour, HOUR_SPEC);
if (coversAll(hour, HOUR_SPEC)) result = {
text: minuteAlone(minute),
frequency: true
};
else if (hourStep !== void 0) result = {
text: `${hourStep}時間ごと(${minuteAfterHours(minute)})`,
frequency: true
};
else if (hourRange !== void 0) result = {
text: `${formatHour(hourRange.from, options)}から${formatHour(hourRange.to, options)}まで${`${hourRange.step}時間ごと(${minuteAfterHours(minute)})`}`,
frequency: false
};
else {
const values = expandField(hour, HOUR_SPEC);
const allPoints = toRanges(values).every(([from, to]) => to - from < 2);
if (allPoints && minute.kind === "single") result = {
text: joinJa(values.map((value) => formatTime(value, minute.value, options))),
frequency: false
};
else if (allPoints) result = {
text: `${joinJa(values.map((value) => `${formatHour(value, options)}台`))}の${minuteWithinHour(minute)}`,
frequency: false
};
else result = {
text: `${describeHourValues(hour, options)}${minuteAfterHours(minute)}`,
frequency: false
};
}
const seconds = ast.seconds;
if (seconds === void 0) return result;
if (seconds.kind === "value" && seconds.value === 0) return result;
if (coversAll(ast.hour, HOUR_SPEC) && coversAll(ast.minute, MINUTE_SPEC)) {
if (coversAll(seconds, SECOND_SPEC)) return {
text: "毎秒",
frequency: true
};
const secondStep = fullRangeStep(seconds, SECOND_SPEC);
if (secondStep !== void 0) return {
text: `${secondStep}秒ごと`,
frequency: true
};
}
return {
text: `${result.text}の${describeSecondField(seconds)}`,
frequency: result.frequency
};
}
/**
* 日付部分を組み立てる。
*/
function describeDate(ast, options) {
const domAll = coversAll(ast.dayOfMonth, DOM_SPEC);
const monthAny = coversAll(ast.month, MONTH_SPEC);
const dowAll = coversAll(ast.dayOfWeek, DOW_SPEC);
const orCoversEveryDay = !isAny(ast.dayOfMonth) && !isAny(ast.dayOfWeek) && (domAll || dowAll);
const domAny = domAll || orCoversEveryDay;
const dowAny = dowAll || orCoversEveryDay;
if (domAny && monthAny && dowAny) return {
text: "毎日",
joinWithNo: false,
everyDay: true
};
const monthText = describeMonthField(ast.month);
if (domAny && !dowAny) {
const dow = describeDayOfWeekField(ast.dayOfWeek, {
weekly: true,
collapse: options.collapseWeekdays
});
return {
text: monthAny ? dow : `${monthText}の${dow}`,
joinWithNo: true,
everyDay: false
};
}
if (!domAny && dowAny) {
const dom = describeDayOfMonthField(ast.dayOfMonth);
if (monthAny) return {
text: `毎月${dom}`,
joinWithNo: true,
everyDay: false
};
if (ast.month.kind === "value" && ast.dayOfMonth.kind === "value") return {
text: `毎年${monthText}${dom}`,
joinWithNo: true,
everyDay: false
};
return {
text: `${monthText}の${dom}`,
joinWithNo: true,
everyDay: false
};
}
if (!domAny && !dowAny) {
const dom = describeDayOfMonthField(ast.dayOfMonth);
const dow = describeDayOfWeekField(ast.dayOfWeek, {
weekly: false,
collapse: options.collapseWeekdays
});
return {
text: `${monthAny ? `毎月${dom}` : ast.month.kind === "value" && ast.dayOfMonth.kind === "value" ? `毎年${monthText}${dom}` : `${monthText}の${dom}`}および${dow}`,
joinWithNo: true,
everyDay: false
};
}
return {
text: `${monthText}の毎日`,
joinWithNo: false,
everyDay: false
};
}
/**
* 日付部分と時刻部分を 1 文に組み立てる。
*/
function compose(ast, options) {
const time = describeTime(ast, options);
const date = describeDate(ast, options);
if (date.everyDay && time.frequency) return time.text;
return `${date.text}${date.joinWithNo ? "の" : ""}${time.text}`;
}
//#endregion
//#region src/explain/index.ts
function resolve(options) {
return {
style: options.style ?? "casual",
hour: options.hour ?? "12h",
collapseWeekdays: options.collapseWeekdays ?? true
};
}
function parserOptions(options) {
return options.seconds === true ? { seconds: true } : {};
}
/**
* cron 式を 1 文の日本語に変換する。
*
* 式は UTC のサーバーで動くものとして読み、`options.tz`(既定 `'Asia/Tokyo'`)の
* 壁時計に直してから日本語にする。
*
* ```ts
* explain('0 4 * * 1-5'); // '平日の午後1時'(UTC 04:00 = JST 13:00)
* explain('0 9 * * 1-5', { tz: 'UTC' }); // '平日の午前9時'(変換しない)
* ```
*
* @throws {CronSyntaxError} 式が不正な場合
* @throws {CronTimeZoneError} `tz` を解釈できない、または cron 式に書き換えられない場合
*/
function explain(expression, options = {}) {
const parsed = parseExpression(expression, parserOptions(options));
const timeZone = resolveTimeZone(options.tz);
const text = compose(shiftAst(parsed.ast, timeZone, "toLocal"), resolve(options));
return options.showTimeZone === true ? `${text}(${timeZone})` : text;
}
function kindOf(ast) {
if (hasExtension(ast) || ast.kind === "noSpecific") return "extension";
switch (ast.kind) {
case "any": return "any";
case "value": return "value";
case "range": return "range";
case "step": return "step";
case "list": return "list";
/* c8 ignore next 2 -- 拡張構文は上で処理済み */
default: return "extension";
}
}
function explainField(raw, ast, spec, text) {
return {
raw,
kind: kindOf(ast),
values: hasExtension(ast) ? [] : expandField(ast, spec),
text
};
}
/**
* cron 式をフィールド別の内訳・注意書き・次回実行日時つきで説明する。
*
* `fields` は `options.tz` の壁時計に直したあとの値を説明する(`localExpression` と対応)。
* `expression` は入力(UTC)を正規化したもの、`next` は UTC として解釈した絶対時刻。
*
* @throws {CronSyntaxError} 式が不正な場合
* @throws {CronTimeZoneError} `tz` を解釈できない、または cron 式に書き換えられない場合
*/
function explainDetailed(expression, options = {}) {
const parsed = parseExpression(expression, parserOptions(options));
const composeOptions = resolve(options);
const timeZone = resolveTimeZone(options.tz);
const ast = shiftAst(parsed.ast, timeZone, "toLocal");
const normalized = formatExpression(parsed.ast);
const localized = ast === parsed.ast ? normalized : formatExpression(ast);
const raw = (field, before, after) => before === after ? parsed.raw[field] ?? formatField(after) : formatField(after);
const dowOptions = {
weekly: false,
collapse: composeOptions.collapseWeekdays
};
const fields = {
minute: explainField(raw("minute", parsed.ast.minute, ast.minute), ast.minute, MINUTE_SPEC, describeMinuteField(ast.minute)),
hour: explainField(raw("hour", parsed.ast.hour, ast.hour), ast.hour, HOUR_SPEC, describeHourField(ast.hour, composeOptions)),
dayOfMonth: explainField(raw("dayOfMonth", parsed.ast.dayOfMonth, ast.dayOfMonth), ast.dayOfMonth, DOM_SPEC, describeDayOfMonthField(ast.dayOfMonth)),
month: explainField(raw("month", parsed.ast.month, ast.month), ast.month, MONTH_SPEC, describeMonthField(ast.month)),
dayOfWeek: explainField(raw("dayOfWeek", parsed.ast.dayOfWeek, ast.dayOfWeek), ast.dayOfWeek, DOW_SPEC, describeDayOfWeekField(ast.dayOfWeek, dowOptions))
};
if (ast.seconds !== void 0) fields.second = explainField(raw("seconds", parsed.ast.seconds ?? ast.seconds, ast.seconds), ast.seconds, SECOND_SPEC, describeSecondField(ast.seconds));
const composed = compose(ast, composeOptions);
const text = options.showTimeZone === true ? `${composed}(${timeZone})` : composed;
const { warnings } = validate(expression, parserOptions(options));
return {
text,
expression: normalized,
localExpression: localized,
tz: timeZone,
fields,
extensions: parsed.extensions,
notes: warnings,
next: next(expression, {
...parserOptions(options),
count: 3
})
};
}
//#endregion
//#region src/parse/dictionary.ts
const DOW = {
日曜日: 0,
日曜: 0,
月曜日: 1,
月曜: 1,
火曜日: 2,
火曜: 2,
水曜日: 3,
水曜: 3,
木曜日: 4,
木曜: 4,
金曜日: 5,
金曜: 5,
土曜日: 6,
土曜: 6
};
const DOW_SET = {
平日: [
1,
2,
3,
4,
5
],
週末: [0, 6],
土日: [0, 6],
休日: [0, 6]
};
/**
* 曜日で近似した語と、そのずれの説明。
*
* 「休日」は祝日・振替休日も指すが、cron は曜日しか持たないので土日に寄せるほかない。
* 黙って寄せると祝日ぶんの取りこぼしに気づけないので、note と減点を付ける(§2.3.5)。
* 減点の量は `fill` が持つ。
*/
const DOW_SET_APPROX = { 休日: "「休日」は祝日も指しますが、cron では曜日しか指定できないため、週末(土日)として解釈しました。祝日には実行されません" };
const TIME_OF_DAY = {
早朝: {
default: 6,
range: [4, 7]
},
朝: {
default: 9,
range: [6, 10]
},
午前: {
default: null,
range: [0, 11]
},
昼: {
default: 12,
range: [11, 13]
},
正午: {
default: 12,
range: [12, 12]
},
午後: {
default: null,
range: [12, 23]
},
夕方: {
default: 18,
range: [16, 19]
},
夜: {
default: 21,
range: [19, 23]
},
晩: {
default: 21,
range: [19, 23]
},
深夜: {
default: 2,
range: [0, 4]
},
夜中: {
default: 2,
range: [0, 4]
}
};
const FREQ = {
毎分: "minute",
毎時: "hour",
毎日: "day",
毎週: "week",
毎月: "month",
毎年: "year"
};
const DOM_SPECIAL = {
月末: "L",
月初: 1
};
const NTH = {
第1: 1,
第2: 2,
第3: 3,
第4: 4,
第5: 5,
最終: -1,
最後: -1
};
//#endregion
//#region src/parse/fill.ts
const ANY = { kind: "any" };
const FREQ_LABELS = {
minute: "毎分",
hour: "毎時",
day: "毎日",
week: "毎週",
month: "毎月",
year: "毎年"
};
const INTERVAL_LABELS = {
minute: "分",
hour: "時",
day: "日",
week: "週",
month: "月"
};
/** 間隔の単位と、その起点になる値の並び */
const INTERVAL_LIST = {
minute: "minute",
day: "dom",
month: "month"
};
function emptyList() {
return {
items: [],
pendingRange: false,
consumed: false
};
}
function pushValue(list, value) {
const last = list.items[list.items.length - 1];
if (list.pendingRange && last !== void 0) {
last.to = value;
list.pendingRange = false;
return;
}
list.items.push({
from: value,
to: value
});
}
/** 値の並びを、連続部分を範囲に畳んだ構文木にする */
function valuesToAst(values) {
const sorted = [...new Set(values)].sort((a, b) => a - b);
const first = sorted[0];
if (first === void 0) return ANY;
if (sorted.length === 1) return {
kind: "value",
value: first
};
const items = [];
let from = first;
let previous = first;
const flush = () => {
if (previous - from >= 2) items.push({
kind: "range",
from,
to: previous
});
else for (let value = from; value <= previous; value++) items.push({
kind: "value",
value
});
};
for (const value of sorted.slice(1)) {
if (value === previous + 1) {
previous = value;
continue;
}
flush();
from = value;
previous = value;
}
flush();
const only = items[0];
/* c8 ignore next -- items は必ず 1 個以上になる */
if (only === void 0) return ANY;
return items.length === 1 ? only : {
kind: "list",
items
};
}
/**
* 値の並びを構文木にする。
* 「AからBまで」と書かれた範囲は範囲のまま残す(`valuesToAst` は 3 個以上の連なりしか
* 範囲に畳まないため、`28,31` のように意味が変わってしまう)。
*/
function spansToAst(spans) {
if (spans.every((span) => span.from === span.to)) return valuesToAst(spans.map((span) => span.from));
const items = spans.map((span) => span.from === span.to ? {
kind: "value",
value: span.from
} : {
kind: "range",
from: span.from,
to: span.to
});
const only = items[0];
/* c8 ignore next -- 範囲を含むなら 1 個以上ある */
if (only === void 0) return ANY;
return items.length === 1 ? only : {
kind: "list",
items
};
}
function listToAst(list) {
return spansToAst(list.items);
}
/**
* 「AからBまでNごと」「AからNごと」の起点を取り出す。
* 範囲として書かれていなければ null を返し、値の並びは値のまま残す。
*/
function consumeBase(list, spec) {
const span = list.items[0];
if (span === void 0 || list.items.length !== 1) return null;
if (!list.pendingRange && span.from === span.to) return null;
list.consumed = true;
return {
kind: "range",
from: span.from,
to: list.pendingRange ? spec.max : span.to
};
}
function pushTime(list, atom) {
const last = list.items[list.items.length - 1];
if (list.pendingRange && last !== void 0) {
last.to = atom;
list.pendingRange = false;
return;
}
list.items.push({
from: atom,
to: null
});
}
/** 刻みの構文木。起点が全域を覆うなら省く(`1-31/3` は「3日ごと」と同じ集合) */
function stepAst(base, step, spec) {
if (base === null) return {
kind: "step",
base: ANY,
step
};
if (base.kind === "range" && base.from === spec.min && base.to === spec.max) return {
kind: "step",
base: ANY,
step
};
return {
kind: "step",
base,
step
};
}
/**
* 時から時間帯語の範囲までの距離(時間)。時計は巡回するので 23 時と 0 時は 1 時間差。
*/
function gapToRange(hour, [low, high]) {
if (hour >= low && hour <= high) return 0;
return Math.min((low - hour + 24) % 24, (hour - high + 24) % 24);
}
/**
* 時間帯語と並んだ時を読む。
*
* 12 時までの数字は 12 時間制の書き方なので、そのままの時と 12 時間ずらした時のうち、
* 語の指す時間帯に近い方を採る(「夜6時」→ 18時、「午後3時」→ 15時)。
* 13 時以降は 24 時間制としか読めないので、そのまま採る(「朝22時」→ 22時)。
*
* 範囲に入っていなくても近い方を採るのは、境界のすぐ外側で読みが裏返らないようにするため。
* 「夜」は 19 時からだが、「夜6時」を午前 6 時と読むのは行き過ぎで、離れたぶんは
* {@link fill} が note と減点で伝える(§2.3.5)。
*
* 12 時のもう一方の読みは 0 時なので、「午前12時」「夜12時」はどちらも 0 時になる。
*/
function readHour(word, hour) {
const range = TIME_OF_DAY[word].range;
const literal = {
hour,
gap: gapToRange(hour, range)
};
if (hour > 12) return literal;
const shifted = (hour + 12) % 24;
const alternative = {
hour: shifted,
gap: gapToRange(shifted, range)
};
return alternative.gap < literal.gap ? alternative : literal;
}
function hourCandidates(from, to) {
const candidates = [];
for (let hour = from; hour <= to; hour++) candidates.push({
value: hour,
label: formatHour(hour, {
style: "casual",
hour: "12h"
})
});
return candidates;
}
function collect(tokens) {
const state = {
times: {
items: [],
pendingRange: false
},
hourSpan: false,
lists: {
minute: emptyList(),
dom: emptyList(),
dow: emptyList(),
month: emptyList()
},
intervals: {},
duplicateIntervals: [],
lastAssign: {},
nths: [],
domSpecial: null,
freqs: [],
unknown: 0,
meaningful: 0,
todNotes: [],
todPenalties: [],
approxNotes: []
};
let pendingWord = null;
/** 範囲の始点に掛かっていた時間帯語。終端に語が無ければこれを引き継ぐ */
let spanWord = null;
let pendingNth = null;
/** 直前に読んだ値がどのスロットのものか(「から」の係り先) */
let last = null;
/**
* 時刻の並びに 1 つ足す。範囲の始点なら、掛かっていた語を終端が引き継げるよう覚える
* (終端に語が無い「夜6時から9時まで」を 18-21 と読むため)。
*/
const addTime = (atom, word) => {
if (!state.times.pendingRange) spanWord = word;
pushTime(state.times, atom);
};
/** 時刻に結び付かなかった時間帯語を、書かれた位置のまま並びに置く */
const flushWord = () => {
if (pendingWord !== null) {
addTime({
kind: "word",
word: pendingWord
}, pendingWord);
pendingWord = null;
}
};
/** 値を並びに足し、「から」の係り先として返す */
const addValues = (name, values) => {
for (const value of values) pushValue(state.lists[name], value);
state.lastAssign[name] = "values";
return name;
};
for (const token of tokens) {
if (token.type !== "UNKNOWN" && token.type !== "SEP") state.meaningful += 1;
switch (token.type) {
case "AMPM":
case "TIME_OF_DAY":
flushWord();
pendingWord = token.value;
last = "time";
break;
case "TIME": {
const time = token.value;
let hour = time.hour;
const inherited = state.times.pendingRange ? spanWord : null;
const word = pendingWord ?? inherited;
if (word !== null) {
const reading = readHour(word, hour);
hour = reading.hour;
if (reading.gap > 0 && pendingWord !== null) {
const [low, high] = TIME_OF_DAY[pendingWord].range;
state.todNotes.push(`「${pendingWord}」は通常 ${low}時から${high}時ですが、${hour}時と解釈しました`);
state.todPenalties.push({
reason: "時間帯の語と時刻のずれ",
amount: Math.min(.3, reading.gap * .1)
});
}
}
addTime({
kind: "time",
hour,
minute: time.minute
}, pendingWord);
pendingWord = null;
last = "time";
break;
}
case "MINUTE":
flushWord();
last = addValues("minute", [token.value]);
break;
case "INTERVAL": {
flushWord();
const interval = token.value;
if (state.intervals[interval.unit] !== void 0) state.duplicateIntervals.push(interval.unit);
state.intervals[interval.unit] = interval.n;
const name = INTERVAL_LIST[interval.unit];
if (name !== void 0) state.lastAssign[name] = "interval";
if (interval.unit === "week") state.freqs.push("week");
if (interval.unit === "month") state.freqs.push("month");
break;
}
case "RANGE_FROM":
if (last === "time") {
flushWord();
if (state.times.items.length > 0) state.times.pendingRange = true;
} else if (last !== null && state.lists[last].items.length > 0) state.lists[last].pendingRange = true;
break;
case "RANGE_TO": break;
case "NTH":
flushWord();
pendingNth = token.value;
break;
case "DOW": {
flushWord();
const weekday = token.value;
if (pendingNth !== null) {
state.nths.push({
weekday,
nth: pendingNth
});
pendingNth = null;
} else last = addValues("dow", [weekday]);
break;
}
case "DOW_SET": {
flushWord();
const approx = DOW_SET_APPROX[token.raw];
if (approx !== void 0) state.approxNotes.push(approx);
last = addValues("dow", token.value);
break;
}
case "DOM":
flushWord();
last = addValues("dom", [token.value]);
break;
case "DOM_SPECIAL": {
flushWord();
const value = token.value;
if (value === "L") state.domSpecial = "L";
else last = addValues("dom", [value]);
break;
}
case "MONTH":
flushWord();
last = addValues("month", [token.value]);
break;
case "FREQ":
flushWord();
state.freqs.push(token.value);
break;
case "AND":
flushWord();
break;
case "HOUR_SPAN":
state.hourSpan = true;
break;
default: if (token.type === "UNKNOWN") state.unknown += 1;
}
}
flushWord();
return state;
}
/**
* トークン列から cron の各フィールドを埋める。
*/
function fill(tokens, options = {}) {
const requested = options.defaultHour ?? 9;
const defaultHour = Number.isInteger(requested) && requested >= 0 && requested <= 23 ? requested : 9;
const state = collect(tokens);
const slots = {
minute: ANY,
hour: ANY,
dom: ANY,
month: ANY,
dow: ANY,
extensions: /* @__PURE__ */ new Set(),
penalties: [],
ambiguities: [],
notes: [...state.todNotes],
empty: state.meaningful === 0
};
if (slots.empty) return slots;
const penalize = (reason, amount) => {
slots.penalties.push({
reason,
amount
});
};
/** 同じフィールドに 2 通りの指定が並んだとき(§2.3.5 の「2 回代入」) */
const noteConflict = (label) => {
slots.notes.push(`「${label}」の指定が 2 通りあるため、後に書かれた方を採用しました`);
penalize("同じフィールドへの二重指定", .2);
};
for (const penalty of state.todPenalties) slots.penalties.push(penalty);
for (const unit of state.duplicateIntervals) noteConflict(INTERVAL_LABELS[unit]);
for (const note of state.approxNotes) {
slots.notes.push(note);
penalize("曜日で近似した語", .2);
}
/** 時間帯の語を時に落とす。幅のある語は曖昧さとして報告する */
const resolveWord = (word) => {
const spec = TIME_OF_DAY[word];
const override = options.timeOfDay?.[word];
if (spec.range[0] !== spec.range[1] && override === void 0) {
slots.ambiguities.push({
field: "hour",
question: `「${word}」は何時ですか?`,
candidates: hourCandidates(spec.range[0], spec.range[1])
});
penalize("時刻を表す語が曖昧", .3);
}
return override ?? spec.default ?? defaultHour;
};
const resolveAtom = (atom) => atom.kind === "time" ? {
hour: atom.hour,
minute: atom.minute
} : {
hour: resolveWord(atom.word),
minute: 0
};
const spans = state.times.items.map((span) => ({
from: resolveAtom(span.from),
to: span.to === null ? null : resolveAtom(span.to)
}));
const minutes = state.lists.minute;
const minuteStep = state.intervals.minute;
const hourStep = state.intervals.hour;
const hasTime = spans.length > 0;
const hasRange = spans.some((span) => span.to !== null);
const spanMinutes = [...new Set(spans.map((span) => span.from.minute))];
/** 時刻の並びを時フィールドにする。書かれた範囲は範囲のまま残す */
const hourList = () => spansToAst(spans.map((span) => ({
from: span.from.hour,
to: (span.to ?? span.from).hour
})));
/**
* 時の刻み。「AからBまでN時間ごと」は範囲ごとに掛かるので、書かれた範囲それぞれに適用する。
* 範囲が 1 つも無ければ起点を持たない全域からの刻み。
*/
const hourStepAst = (step) => {
if (!hasRange) return {
kind: "step",
base: ANY,
step
};
const items = spans.map((span) => span.to === null ? {
kind: "value",
value: span.from.hour
} : stepAst({
kind: "range",
from: span.from.hour,
to: span.to.hour
}, step, HOUR_SPEC));
const only = items[0];
/* c8 ignore next -- hasRange が真なら 1 個以上ある */
if (only === void 0) return {
kind: "step",
base: ANY,
step
};
return items.length === 1 ? only : {
kind: "list",
items
};
};
/** 「N時まで」が N 時台を含むことを断る。終端ごとに曖昧なので範囲の数だけ付ける */
const noteHourRanges = () => {
for (const span of spans) {
const to = span.to;
if (to === null) continue;
slots.notes.push(`「${to.hour}時まで」を ${to.hour}時台まで(${span.from.hour}-${to.hour})と解釈しました。${to.hour}:00 で終える場合は ${span.from.hour}-${to.hour - 1} を指定してください`);
penalize("時刻範囲の終端が曖昧", .1);
}
};
if (state.freqs.includes("minute")) {
slots.minute = ANY;
if (hourStep !== void 0) slots.hour = hourStepAst(hourStep);
else if (hasTime) slots.hour = hourList();
noteHourRanges();
} else if (minuteStep !== void 0 || hourStep !== void 0) {
if (minuteStep !== void 0) slots.minute = stepAst(consumeBase(minutes, MINUTE_SPEC), minuteStep, MINUTE_SPEC);
if (hourStep !== void 0) {
slots.hour = hourStepAst(hourStep);
if (minuteStep === void 0) {
slots.minute = minutes.items.length > 0 ? listToAst(minutes) : {
kind: "value",
value: spanMinutes[0] ?? 0
};
minutes.consumed = true;
}
} else if (hasRange) slots.hour = hourList();
noteHourRanges();
if (!hasRange && hasTime) {
if (minuteStep !== void 0) slots.hour = hourList();
if (!(state.hourSpan && minuteStep !== void 0 && hourStep === void 0)) {
slots.ambiguities.push({
field: "hour",
question: "「〜ごと」と時刻の両方が指定されています。どちらの意味ですか?",
candidates: [{
value: "interval",
label: "指定した間隔で繰り返す"
}, {
value: "at",
label: "指定した時刻にだけ実行する"
}]
});
penalize("間隔と時刻の併用", .2);
}
}
if (minutes.items.length > 0 && !minutes.consumed) {
noteConflict(INTERVAL_LABELS.minute);
if (state.lastAssign.minute === "values") slots.minute = listToAst(minutes);
}
} else if (hasTime) {
const writtenMinutes = minutes.items.length > 0;
if (writtenMinutes && spanMinutes.some((minute) => minute !== 0)) noteConflict(INTERVAL_LABELS.minute);
slots.hour = hourList();
noteHourRanges();
const only = spanMinutes[0];
if (writtenMinutes) slots.minute = listToAst(minutes);
else if (spanMinutes.length === 1 && only !== void 0) slots.minute = {
kind: "value",
value: only
};
else {
slots.minute = valuesToAst(spanMinutes);
slots.notes.push("複数の時刻が指定されているため、時と分のすべての組み合わせで実行されます");
penalize("時と分の組み合わせが曖昧", .1);
}
} else if (state.freqs.includes("hour")) {
slots.hour = ANY;
slots.minute = minutes.items.length > 0 ? listToAst(minutes) : {
kind: "value",
value: 0
};
} else if (minutes.items.length > 0) {
slots.hour = ANY;
slots.minute = listToAst(minutes);
} else {
slots.hour = {
kind: "value",
value: defaultHour
};
slots.minute = {
kind: "value",
value: 0
};
const freqLabel = state.freqs[0] === void 0 ? void 0 : FREQ_LABELS[state.freqs[0]];
slots.ambiguities.push({
field: "hour",
question: freqLabel === void 0 ? "何時に実行しますか?" : `「${freqLabel}」は何時ですか?`,
candidates: hourCandidates(0, 23)
});
penalize("時刻が指定されていない", .4);
}
const doms = state.lists.dom;
const dows = state.lists.dow;
const months = state.lists.month;
const dayStep = state.intervals.day;
const monthStep = state.intervals.month;
if (state.nths.length > 0) {
const items = state.nths.map((entry) => ({
kind: "nth",
weekday: entry.weekday,
nth: entry.nth
}));
const first = items[0];
/* c8 ignore next -- nths.length > 0 なら必ず取れる */
slots.dow = items.length === 1 && first !== void 0 ? first : {
kind: "list",
items
};
for (const entry of state.nths) slots.extensions.add(entry.nth === -1 ? "L" : "#");
} else if (dows.items.length > 0) slots.dow = listToAst(dows);
if (state.domSpecial === "L") {
slots.dom = { kind: "last" };
slots.extensions.add("L");
if (dayStep !== void 0) noteConflict(INTERVAL_LABELS.day);
} else if (dayStep !== void 0) {
slots.dom = stepAst(consumeBase(doms, DOM_SPEC), dayStep, DOM_SPEC);
if (doms.items.length > 0 && !doms.consumed) {
noteConflict(INTERVAL_LABELS.day);
if (state.lastAssign.dom === "values") slots.dom = listToAst(doms);
}
} else if (doms.items.length > 0) slots.dom = listToAst(doms);
if (monthStep !== void 0) {
slots.month = stepAst(consumeBase(months, MONTH_SPEC), monthStep, MONTH_SPEC);
if (months.items.length > 0 && !months.consumed) {
noteConflict(INTERVAL_LABELS.month);
if (state.lastAssign.month === "values") slots.month = listToAst(months);
}
} else if (months.items.length > 0) slots.month = listToAst(months);
const weekStep = state.intervals.week;
if (weekStep !== void 0) {
slots.notes.push(`cron には週単位の刻みが無いため「${weekStep}週間ごと」は表現できません。毎週として解釈しました`);
penalize("週単位の刻み", .3);
}
const domSpecified = slots.dom.kind !== "any";
const dowSpecified = slots.dow.kind !== "any";
for (const freq of state.freqs) {
if (freq === "day" && (domSpecified || dowSpecified)) {
slots.notes.push("「毎日」と日付・曜日の指定が同時にあるため、日付・曜日の指定を優先しました");
penalize("頻度語と日付指定の不一致", .1);
}
if (freq === "week" && !dowSpecified) {
slots.dow = {
kind: "value",
value: 1
};
slots.ambiguities.push({
field: "dayOfWeek",
question: "「毎週」は何曜日ですか?",
candidates: [
{
value: 0,
label: "日曜日"
},
{
value: 1,
label: "月曜日"
},
{
value: 2,
label: "火曜日"
},
{
value: 3,
label: "水曜日"
},
{
value: 4,
label: "木曜日"
},
{
value: 5,
label: "金曜日"
},
{
value: 6,
label: "土曜日"
}
]
});
penalize("「毎週」の曜日が未指定", .3);
}
if (freq === "month" && !domSpecified && !dowSpecified && !state.freqs.includes("day")) {
slots.dom = {
kind: "value",
value: 1
};
slots.ambiguities.push({
field: "dayOfMonth",
question: "「毎月」は何日ですか?",
candidates: [
{
value: 1,
label: "1日"
},
{
value: 15,
label: "15日"
},
{
value: "L",
label: "月末"
}
]
});
penalize("「毎月」の日が未指定", .3);
}
if (freq === "year" && slots.month.kind === "any") {
slots.month = {
kind: "value",
value: 1
};
if (slots.dom.kind === "any") slots.dom = {
kind: "value",
value: 1
};
slots.ambiguities.push({
field: "month",
question: "「毎年」は何月ですか?",
candidates: Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: `${index + 1}月`
}))
});
penalize("「毎年」の月が未指定", .3);
}
}
if (slots.dom.kind !== "any" && slots.dow.kind !== "any") {
slots.notes.push("標準の cron では日と曜日を同時に指定すると OR 条件になり、どちらかに一致する日に実行されます");
penalize("日と曜日の同時指定", .2);
}
const hasDateToken = dows.items.length > 0 || state.nths.length > 0 || doms.items.length > 0 || state.domSpecial !== null || months.items.length > 0;
const hasInterval = Object.keys(state.intervals).length > 0;
if (state.freqs.length === 0 && !hasDateToken && !hasInterval) {
slots.notes.push("頻度が明示されていないため、毎日として解釈しました");
penalize("頻度語が無い", .2);
}
if (slots.extensions.size > 0 && options.allowExtensions !== true) {
const used = [...slots.extensions].join("', '");
slots.notes.push(`'${used}' は Quartz 拡張です。標準の cron では動作しません(allowExtensions で抑制できます)`);
penalize("拡張構文の使用", .1);
}
if (state.unknown > 0) penalize("解釈できない語", Math.min(.3, state.unknown * .1));
return slots;
}
/** スロットを cron 式にする */
function emit(slots) {
return [
slots.minute,
slots.hour,
slots.dom,
slots.month,
slots.dow
].map(formatField).join(" ");
}
//#endregion
//#region src/parse/normalize.ts
const KANJI_DIGITS = {
〇: 0,
零: 0,
一: 1,
二: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9
};
const KANJI_RUN = /[〇零一二三四五六七八九十]+/g;
function convertRun(run) {
if (!run.includes("十")) {
let digits = "";
for (const char of run) {
const digit = KANJI_DIGITS[char];
/* c8 ignore next -- KANJI_RUN が一致した文字は必ず辞書にある */
if (digit === void 0) return run;
digits += String(digit);
}
return digits;
}
let total = 0;
let current = 0;
for (const char of run) if (char === "十") {
total += (current === 0 ? 1 : current) * 10;
current = 0;
} else {
const digit = KANJI_DIGITS[char];
/* c8 ignore next -- KANJI_RUN が一致した文字は必ず辞書にある */
if (digit === void 0) return run;
current = digit;
}
return String(total + current);
}
/** 漢数字を算用数字に変換する(「半」はそのまま残す) */
function kanjiToArabic(text) {
return text.replace(KANJI_RUN, (run) => convertRun(run));
}
/** 文末の動詞句・丁寧表現を落とす */
const TAIL_PATTERNS = [
/(に|で)?(実行|起動|開始|動作)(する|して|し|す)?(たい|ください|下さい|ます|ましょう|よう)?$/,
/(して)?(ください|下さい|ほしい|欲しい|たい)$/,
/(です|ます|だ|である)(か)?$/,
/[。!!??、,\s]+$/
];
function stripTail(text) {
let result = text;
for (let i = 0; i < 5; i++) {
let changed = false;
for (const pattern of TAIL_PATTERNS) {
const next = result.replace(pattern, "");
if (next !== result && next.length > 0) {
result = next;
changed = true;
}
}
if (!changed) break;
}
return result;
}
/**
* 解析前の前処理。NFKC 正規化 → 漢数字変換 → 文末表現の除去。
*/
function normalize(text) {
return stripTail(kanjiToArabic(text.normalize("NFKC")).trim()).trim();
}
//#endregion
//#region src/parse/tokenize.ts
function int(text) {
return Number(text ?? "0");
}
/** 範囲外の数値はトークンにしない(後続のルール、最終的には UNKNOWN に回す) */
function inRange(value, min, max) {
return Number.isInteger(value) && value >= min && value <= max;
}
/**
* 長い表現から順に試すため、並び順に意味がある。
*
* 「毎」で終わる間隔(「15分毎」)は、後ろに頻度語の単位が続く場合だけ採らない。
* 「1月毎日」を「1か月ごと」+「日」と切ってしまうのを避けるため。
*/
const RULES = [
[/^(\d{1,2})時間(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
const n = int(m[1]);
if (!inRange(n, 1, 23)) return null;
return {
type: "INTERVAL",
value: {
unit: "hour",
n
}
};
}],
[/^(\d{1,2})分(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
const n = int(m[1]);
if (!inRange(n, 1, 59)) return null;
return {
type: "INTERVAL",
value: {
unit: "minute",
n
}
};
}],
[/^(\d{1,2})日(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
const n = int(m[1]);
if (!inRange(n, 1, 31)) return null;
return {
type: "INTERVAL",
value: {
unit: "day",
n
}
};
}],
[/^(\d{1,2})[かカヵケヶ箇]?月(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
const n = int(m[1]);
if (!inRange(n, 1, 12)) return null;
return {
type: "INTERVAL",
value: {
unit: "month",
n
}
};
}],
[/^(\d{1,2})週間?(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
const n = int(m[1]);
if (!inRange(n, 1, 52)) return null;
return {
type: "INTERVAL",
value: {
unit: "week",
n
}
};
}],
[/^(\d{1,2}):(\d{2})/, (m) => {
const hour = int(m[1]);
const minute = int(m[2]);
if (!inRange(hour, 0, 23) || !inRange(minute, 0, 59)) return null;
return {
type: "TIME",
value: {
hour,
minute
}
};
}],
[/^(\d{1,2})時(半|(\d{1,2})分)?/, (m) => {
const hour = int(m[1]);
const minute = m[2] === "半" ? 30 : int(m[3]);
if (!inRange(hour, 0, 23) || !inRange(minute, 0, 59)) return null;
return {
type: "TIME",
value: {
hour,
minute
}
};
}],
[/^(\d{1,2})分/, (m) => {
const value = int(m[1]);
return inRange(value, 0, 59) ? {
type: "MINUTE",
value
} : null;
}],
[/^(第[1-5]|最終|最後)(週|の)?/, (m) => {
const nth = NTH[m[1] ?? ""];
return nth === void 0 ? null : {
type: "NTH",
value: nth
};
}],
[/^(\d{1,2})月/, (m) => {
const value = int(m[1]);
return inRange(value, 1, 12) ? {
type: "MONTH",
value
} : null;
}],
[/^(\d{1,2})日/, (m) => {
const value = int(m[1]);
return inRange(value, 1, 31) ? {
type: "DOM",
value
} : null;
}],
[/^(平日|週末|土日|休日)/, (m) => {
const values = DOW_SET[m[1] ?? ""];
return values === void 0 ? null : {
type: "DOW_SET",
value: [...values]
};
}],
[/^(日|月|火|水|木|金|土)曜日?/, (m) => {
const value = DOW[`${m[1]}曜`];
return value === void 0 ? null : {
type: "DOW",
value
};
}],
[/^毎?(月末|月初)/, (m) => {
const value = DOM_SPECIAL[m[1] ?? ""];
return value === void 0 ? null : {
type: "DOM_SPECIAL",
value
};
}],
[/^(早朝|朝|正午|昼|夕方|夜中|深夜|夜|晩|午前|午後)/, (m) => {
const word = m[1];
if (word === "午前" || word === "午後") return {
type: "AMPM",
value: word
};
return {
type: "TIME_OF_DAY",
value: word
};
}],
[/^毎(分|時|日|週|月|年)/, (m) => {
const unit = FREQ[`毎${m[1]}`];
return unit === void 0 ? null : {
type: "FREQ",
value: unit
};
}],
[/^毎(?=早朝|朝|正午|昼|夕方|夜中|深夜|夜|晩)/, () => ({
type: "FREQ",
value: "day"
})],
[/^(から|以降)/, () => ({ type: "RANGE_FROM" })],
[/^(まで|以前)/, () => ({ type: "RANGE_TO" })],
[/^(および|かつ|と|、|,)/, () => ({ type: "AND" })],
[/^台/, () => ({ type: "HOUR_SPAN" })],
[/^(の|に|は|が|を|\s|。)+/, () => ({ type: "SEP" })]
];
/**
* 正規化済みの日本語をトークン列に分解する。
*/
function tokenize(text) {
const tokens = [];
let position = 0;
while (position < text.length) {
const rest = text.slice(position);
let matched = false;
for (const [pattern, build] of RULES) {
const match = pattern.exec(rest);
if (match === null || match[0] === "") continue;
const token = build(match);
/* c8 ignore next -- 辞書引きに失敗した場合のみ。通常は起きない */
if (token === null) continue;
tokens.push({
...token,
raw: match[0],
position
});
position += match[0].length;
matched = true;
break;
}
if (!matched) {
tokens.push({
type: "UNKNOWN",
raw: text[position] ?? "",
position
});
position += 1;
}
}
return tokens;
}
//#endregion
//#region src/parse/index.ts
function round2(value) {
return Math.round(value * 100) / 100;
}
/**
* 日本語の予定表現を cron 式に変換する。
*
* 日本語は `options.tz`(既定 `'Asia/Tokyo'`)の壁時計として読み、UTC のサーバーで
* 動かすための cron 式を返す。
*
* ```ts
* parse('毎日午後1時').expression; // '0 4 * * *'(JST 13:00 = UTC 04:00)
* parse('平日の朝9時', { tz: 'UTC' }).expression; // '0 9 * * 1-5'(変換しない)
* ```
*
* @throws {ParseAmbiguityError} `strict: true` かつ解釈が曖昧な場合
* @throws {CronTimeZoneError} `tz` を解釈できない、または UTC の cron 式に書き換えられない場合
*/
function parse(text, options = {}) {
const timeZone = resolveTimeZone(options.tz);
const tokens = tokenize(normalize(text));
const slots = fill(tokens, options);
if (slots.empty) {
const result = {
expression: null,
localExpression: null,
tz: timeZone,
confidence: 0,
ambiguities: [],
notes: ["時間表現が見つかりませんでした"],
tokens
};
if (options.strict === true) throw new ParseAmbiguityError("時間表現が見つかりませんでした", result);
return result;
}
const total = slots.penalties.reduce((sum, penalty) => sum + penalty.amount, 0);
const local = emit(slots);
const result = {
expression: shiftExpression(local, timeZone, "toServer"),
localExpression: local,
tz: timeZone,
confidence: round2(Math.max(0, 1 - total)),
ambiguities: slots.ambiguities,
notes: slots.notes,
tokens
};
if (options.strict === true && result.ambiguities.length > 0) {
const first = result.ambiguities[0];
throw new ParseAmbiguityError(`解釈が曖昧です: ${first?.question ?? "入力を特定できません"}`, result);
}
return result;
}
//#endregion
exports.CronSyntaxError = CronSyntaxError;
exports.CronTimeZoneError = CronTimeZoneError;
exports.ParseAmbiguityError = ParseAmbiguityError;
exports.explain = explain;
exports.explainDetailed = explainDetailed;
exports.next = next;
exports.parse = parse;
exports.validate = validate;
//# sourceMappingURL=index.cjs.map