Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
184 lines
14 KiB
JavaScript
184 lines
14 KiB
JavaScript
'use strict';
|
|
// ============================================================================
|
|
// 일정관리 모듈 — 일정 데이터 규칙과 발생일 전개 (v1.4.18, 요구 1)
|
|
//
|
|
// 일정(event)은 사이트 관리자 페이지에서 등록하고 data/modules/<사이트>.json 의 events 에 저장한다(lib/modules.js).
|
|
// 이 파일은 (1) 관리자 입력을 저장 형식으로 정리(normalizeEvent)하고 (2) 캘린더가 요청한 기간 안에서 반복·음력 일정을
|
|
// 실제 날짜(발생, occurrence)로 펼치며(expand) (3) 캘린더 한 칸에 보일 음력·간지 정보(lib/lunar.js)를 붙인다(range).
|
|
//
|
|
// 저장 형식 (event)
|
|
// id, title, body(설명), location, category, color, published
|
|
// unit : 'time' 시·분 단위(한 날의 시작~종료 시각) | 'day' 일 단위(length 일) | 'week' 주 단위(length 주)
|
|
// cal : 'solar' 양력 | 'lunar' 음력 — 음력이면 lunar:{ year, month, day, leap } 가 시작일(양력 date 는 변환해 함께 저장)
|
|
// date : 양력 시작일 'YYYY-MM-DD' (cal=lunar 여도 첫 발생일을 변환해 둔다 — 목록 정렬용)
|
|
// time, endTime : unit=time 일 때 'HH:MM'
|
|
// length : unit=day → 며칠, unit=week → 몇 주 (기본 1)
|
|
// repeat : { freq:'none'|'daily'|'weekly'|'monthly'|'yearly', interval(1~99), until:'YYYY-MM-DD'|'', weekdays:[0..6] }
|
|
// 음력 일정의 monthly 는 "음력 매월 같은 날", yearly 는 "음력 매년 같은 달·날" 이다. 윤달 시작 일정은 그 해에
|
|
// 같은 윤달이 있을 때만 윤달로, 없으면 평달로 잡는다(윤달 생일을 평달에 지내는 관행). 없는 날(30일)은 그 달 마지막 날로 당긴다.
|
|
//
|
|
// 발생(occurrence) — 캘린더가 그리는 단위
|
|
// { id:'<eventId>@<YYYY-MM-DD>', eventId, title, body, location, category, color, unit, allDay, start:'YYYY-MM-DD', end:'YYYY-MM-DD'(마지막 날, 포함),
|
|
// days, time, endTime, cal, lunarText('음 8.15' 등 음력 일정일 때) }
|
|
// ============================================================================
|
|
const lunar = require('./lunar');
|
|
const sitemsg = require('./sitemsg');
|
|
const E = (id, status = 400, vars) => sitemsg.err(id, status, vars);
|
|
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
const UNITS = ['time', 'day', 'week'];
|
|
const FREQS = ['none', 'daily', 'weekly', 'monthly', 'yearly'];
|
|
const COLORS = ['', 'blue', 'green', 'red', 'orange', 'purple', 'gray']; // 위젯 CSS 클래스 wm-cal-c-<색>
|
|
const MAX_RANGE_DAYS = 70; // 한 번에 조회할 수 있는 최대 기간 (월간 보기 6주 = 42일 + 여유)
|
|
const MAX_OCCURRENCES = 2000; // 한 조회에서 펼치는 발생 상한 (무한 반복 방어)
|
|
const clip = (v, n) => String(v == null ? '' : v).trim().slice(0, n);
|
|
|
|
/** 'YYYY-MM-DD' → JDN (잘못된 형식이면 null) */
|
|
function jdnOf(s) { const m = DATE_RE.exec(String(s || '')); if (!m) return null; const y = +m[1], mo = +m[2], d = +m[3]; if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; const j = lunar.jdn(y, mo, d); const back = lunar.dateFromJdn(j); return back.y === y && back.m === mo && back.d === d ? j : null; }
|
|
/** JDN → 'YYYY-MM-DD' */
|
|
function dateOf(j) { const { y, m, d } = lunar.dateFromJdn(j); return `${y}-${pad(m)}-${pad(d)}`; }
|
|
/** JDN → 요일 (0=일) : JDN 0 은 월요일이므로 +1 */
|
|
const weekdayOf = (j) => (j + 1) % 7;
|
|
/** 시각 'HH:MM' → 분 */
|
|
const minutesOf = (t) => { const m = TIME_RE.exec(t || ''); return m ? +m[1] * 60 + +m[2] : null; };
|
|
|
|
/**
|
|
* 관리자 입력 → 저장 형식. 잘못된 값은 오류(사이트 문구 id)로 돌려준다.
|
|
* @param {object} body 입력 (문자열 값 허용: 체크박스·숫자는 문자열로 올 수 있음)
|
|
* @param {object} [prev] 수정할 때 기존 일정 (없는 항목은 기존 값 유지)
|
|
*/
|
|
function normalizeEvent(body, prev) {
|
|
const b = body || {}; const p = prev || {};
|
|
const get = (k, def) => (b[k] !== undefined ? b[k] : (p[k] !== undefined ? p[k] : def));
|
|
const title = clip(get('title', ''), 120); if (!title) throw E('err.requiredFields');
|
|
const unit = UNITS.includes(get('unit', 'day')) ? get('unit', 'day') : 'day';
|
|
const cal = get('cal', 'solar') === 'lunar' ? 'lunar' : 'solar';
|
|
const ev = { id: p.id, title, body: clip(get('body', ''), 5000), location: clip(get('location', ''), 200), category: clip(get('category', ''), 40), color: COLORS.includes(get('color', '')) ? get('color', '') : '', unit, cal, published: !(get('published', true) === false || get('published', true) === 'false') };
|
|
// 시작일: 양력 또는 음력
|
|
if (cal === 'lunar') {
|
|
const ln = b.lunar && typeof b.lunar === 'object' ? b.lunar : (p.lunar || {});
|
|
const ly = parseInt(ln.year, 10), lm = parseInt(ln.month, 10), ld = parseInt(ln.day, 10); const leap = ln.leap === true || ln.leap === 'true' || ln.leap === 'on';
|
|
if (!(ly >= lunar.RANGE[0] && ly <= lunar.RANGE[1]) || !(lm >= 1 && lm <= 12) || !(ld >= 1 && ld <= 30)) throw E('err.eventDate');
|
|
const sol = lunar.lunarToSolar(ly, lm, ld, leap, true); if (!sol) throw E('err.eventDate');
|
|
ev.lunar = { year: ly, month: lm, day: ld, leap }; ev.date = `${sol.y}-${pad(sol.m)}-${pad(sol.d)}`;
|
|
} else {
|
|
const j = jdnOf(get('date', '')); if (!j) throw E('err.eventDate'); ev.date = dateOf(j); ev.lunar = null;
|
|
}
|
|
// 시간 단위: 시작·종료 시각
|
|
if (unit === 'time') {
|
|
const t = String(get('time', '')); const et = String(get('endTime', '')); if (!TIME_RE.test(t)) throw E('err.eventTime');
|
|
ev.time = t; ev.endTime = TIME_RE.test(et) && minutesOf(et) > minutesOf(t) ? et : '';
|
|
} else { ev.time = ''; ev.endTime = ''; }
|
|
const len = parseInt(get('length', 1), 10); ev.length = unit === 'time' ? 1 : Math.min(unit === 'week' ? 52 : 366, Math.max(1, Number.isFinite(len) ? len : 1));
|
|
// 반복
|
|
const r = b.repeat && typeof b.repeat === 'object' ? b.repeat : (p.repeat || {});
|
|
const freq = FREQS.includes(r.freq) ? r.freq : 'none'; const iv = parseInt(r.interval, 10);
|
|
const until = r.until ? (jdnOf(r.until) ? dateOf(jdnOf(r.until)) : '') : '';
|
|
const weekdays = Array.isArray(r.weekdays) ? r.weekdays.map((x) => parseInt(x, 10)).filter((x) => x >= 0 && x <= 6) : String(r.weekdays || '').split(',').map((x) => parseInt(x, 10)).filter((x) => x >= 0 && x <= 6);
|
|
ev.repeat = { freq, interval: freq === 'none' ? 1 : Math.min(99, Math.max(1, Number.isFinite(iv) ? iv : 1)), until, weekdays: freq === 'weekly' ? [...new Set(weekdays)].sort() : [] };
|
|
return ev;
|
|
}
|
|
|
|
/** 일정 한 건의 발생 시작일(JDN) 목록을 [from, to] 와 겹치도록 만든다. dur 은 발생 하나의 길이(일). */
|
|
function startsOf(ev, fromJ, toJ, dur) {
|
|
const baseJ = jdnOf(ev.date); if (baseJ == null) return [];
|
|
const untilJ = ev.repeat && ev.repeat.until ? jdnOf(ev.repeat.until) : null;
|
|
const limit = untilJ != null ? Math.min(untilJ, toJ) : toJ; // 이 날 이후에 시작하는 발생은 필요 없다
|
|
const freq = (ev.repeat && ev.repeat.freq) || 'none'; const iv = (ev.repeat && ev.repeat.interval) || 1;
|
|
const out = []; const push = (j) => { if (j >= baseJ && j <= limit && j + dur - 1 >= fromJ) out.push(j); };
|
|
if (freq === 'none') { push(baseJ); return out; }
|
|
if (freq === 'daily') { const step = iv; let j = baseJ; if (fromJ > baseJ) j = baseJ + Math.floor((fromJ - dur + 1 - baseJ) / step) * step; if (j < baseJ) j = baseJ; for (; j <= limit && out.length < MAX_OCCURRENCES; j += step) push(j); return out; }
|
|
if (freq === 'weekly') {
|
|
const wds = ev.repeat.weekdays && ev.repeat.weekdays.length ? ev.repeat.weekdays : [weekdayOf(baseJ)];
|
|
const week0 = baseJ - weekdayOf(baseJ); // 시작일이 든 주의 일요일
|
|
let n = 0; if (fromJ > baseJ) n = Math.max(0, Math.floor((fromJ - dur + 1 - week0) / (7 * iv)));
|
|
for (; week0 + 7 * iv * n <= limit && out.length < MAX_OCCURRENCES; n++) for (const wd of wds) push(week0 + 7 * iv * n + wd);
|
|
return out;
|
|
}
|
|
if (ev.cal === 'lunar' && ev.lunar) {
|
|
const { month, day, leap } = ev.lunar; const fromY = lunar.dateFromJdn(Math.max(fromJ - dur, baseJ)).y - 1; const toY = lunar.dateFromJdn(limit).y + 1;
|
|
if (freq === 'yearly') {
|
|
for (let y = ev.lunar.year; y <= toY && out.length < MAX_OCCURRENCES; y += iv) { if (y < fromY) continue; try { const s = lunar.lunarToSolar(y, month, day, leap, true); if (s) push(lunar.jdn(s.y, s.m, s.d)); } catch (_) { break; } }
|
|
return out;
|
|
}
|
|
// monthly: 음력 달을 차례로 세며 같은 날짜 (윤달도 한 달로 센다)
|
|
let count = 0;
|
|
for (let y = ev.lunar.year; y <= toY && out.length < MAX_OCCURRENCES; y++) {
|
|
let months; try { months = lunar.lunarYearMonths(y); } catch (_) { break; }
|
|
for (const mo of months) {
|
|
if (y === ev.lunar.year && (mo.month < month || (mo.month === month && !mo.leap && leap))) continue; // 시작 달 이전은 건너뜀
|
|
if (count % iv === 0) { const d = Math.min(day, mo.days); const j = lunar.jdn(mo.start.y, mo.start.m, mo.start.d) + d - 1; if (j > limit) return out; push(j); }
|
|
count += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
// 양력 monthly / yearly: 같은 날짜, 없는 날(31일·2월 29일)은 그 달 마지막 날
|
|
const base = lunar.dateFromJdn(baseJ);
|
|
const lastDay = (y, m) => lunar.jdn(y, m, 1) + (m === 12 ? lunar.jdn(y + 1, 1, 1) : lunar.jdn(y, m + 1, 1)) - lunar.jdn(y, m, 1) - 1;
|
|
if (freq === 'monthly') {
|
|
let n = 0; if (fromJ > baseJ) n = Math.max(0, Math.floor(((fromJ - dur + 1) - baseJ) / 31 / iv) * iv - iv);
|
|
for (; out.length < MAX_OCCURRENCES; n += iv) { const total = base.m - 1 + n; const y = base.y + Math.floor(total / 12); const m = total % 12 + 1; const first = lunar.jdn(y, m, 1); if (first > limit) break; push(Math.min(first + base.d - 1, lastDay(y, m))); }
|
|
return out;
|
|
}
|
|
for (let y = base.y; out.length < MAX_OCCURRENCES; y += iv) { const first = lunar.jdn(y, base.m, 1); if (first > limit) break; if (y + 1 < lunar.dateFromJdn(fromJ).y) continue; push(Math.min(first + base.d - 1, lastDay(y, base.m))); }
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 일정 목록을 기간 [from, to] (JDN, 양끝 포함) 의 발생으로 펼친다.
|
|
* @param {object[]} events 저장된 일정
|
|
* @param {object} [opt] { category: 분류 필터, publishedOnly: 공개 일정만(방문자) }
|
|
*/
|
|
function expand(events, fromJ, toJ, opt = {}) {
|
|
const out = [];
|
|
for (const ev of events) {
|
|
if (opt.publishedOnly && ev.published === false) continue; if (opt.category && ev.category !== opt.category) continue;
|
|
const dur = ev.unit === 'week' ? 7 * (ev.length || 1) : ev.unit === 'day' ? (ev.length || 1) : 1;
|
|
for (const j of startsOf(ev, fromJ, toJ, dur)) {
|
|
const o = { id: `${ev.id}@${dateOf(j)}`, eventId: ev.id, title: ev.title, body: ev.body || '', location: ev.location || '', category: ev.category || '', color: ev.color || '', unit: ev.unit, allDay: ev.unit !== 'time', start: dateOf(j), end: dateOf(j + dur - 1), days: dur, time: ev.time || '', endTime: ev.endTime || '', cal: ev.cal || 'solar', published: ev.published !== false };
|
|
if (ev.cal === 'lunar') { try { const l = lunar.solarToLunar(...dateOf(j).split('-').map(Number)); o.lunarText = `${l.leap ? '윤' : ''}${l.month}.${l.day}`; } catch (_) {} }
|
|
out.push(o);
|
|
}
|
|
if (out.length >= MAX_OCCURRENCES) break;
|
|
}
|
|
return out.sort((a, b) => a.start.localeCompare(b.start) || (a.allDay === b.allDay ? (a.time || '').localeCompare(b.time || '') : (a.allDay ? -1 : 1)));
|
|
}
|
|
|
|
/**
|
|
* 캘린더 조회: 기간의 날짜 정보(음력·간지·절기)와 발생 일정.
|
|
* @param {object[]} events 저장된 일정
|
|
* @param {string} from 'YYYY-MM-DD'
|
|
* @param {string} to 'YYYY-MM-DD' (포함)
|
|
* @param {object} opt { category, publishedOnly, withDays }
|
|
*/
|
|
function range(events, from, to, opt = {}) {
|
|
const fj = jdnOf(from), tj = jdnOf(to); if (fj == null || tj == null || tj < fj) throw E('err.dateFormat');
|
|
if (tj - fj + 1 > MAX_RANGE_DAYS) throw E('err.rangeTooLong', 400, { n: MAX_RANGE_DAYS });
|
|
const days = [];
|
|
if (opt.withDays !== false) for (let j = fj; j <= tj; j++) { const { y, m, d } = lunar.dateFromJdn(j); try { const info = lunar.dayInfo(y, m, d); days.push({ date: info.date, lunar: { month: info.lunar.month, day: info.lunar.day, leap: info.lunar.leap, text: info.lunar.text }, ganji: { year: info.ganji.year, month: info.ganji.month, day: info.ganji.day, text: info.ganji.text, textHanja: info.ganji.textHanja }, term: info.term, termHanja: info.termHanja }); } catch (_) { days.push({ date: dateOf(j) }); } }
|
|
return { from: dateOf(fj), to: dateOf(tj), days, events: expand(events, fj, tj, opt) };
|
|
}
|
|
|
|
/** 오늘 이후 첫 발생일 (관리자 목록 표시용). 2년 안에 없으면 null */
|
|
function nextOccurrence(ev, today = new Date()) {
|
|
const t = lunar.jdn(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
|
const dur = ev.unit === 'week' ? 7 * (ev.length || 1) : ev.unit === 'day' ? (ev.length || 1) : 1;
|
|
const s = startsOf(ev, t, t + 730, dur).filter((j) => j + dur - 1 >= t); return s.length ? dateOf(s[0]) : null;
|
|
}
|
|
|
|
/** 양력 한 날의 음력·간지·절기 (관리자 "날짜 조회", 위젯 일간 보기) */
|
|
function dayInfo(dateStr) { const j = jdnOf(dateStr); if (j == null) throw E('err.dateFormat'); const { y, m, d } = lunar.dateFromJdn(j); return lunar.dayInfo(y, m, d); }
|
|
/** 음력 → 양력 (관리자 입력 미리보기). 없는 날은 null */
|
|
function lunarToSolar(q) {
|
|
const y = parseInt(q.year, 10), m = parseInt(q.month, 10), d = parseInt(q.day, 10); const leap = q.leap === '1' || q.leap === 'true' || q.leap === true;
|
|
if (!(y >= lunar.RANGE[0] && y <= lunar.RANGE[1]) || !(m >= 1 && m <= 12) || !(d >= 1 && d <= 30)) throw E('err.eventDate');
|
|
const months = lunar.lunarYearMonths(y); const mo = months.find((x) => x.month === m && x.leap === leap);
|
|
const s = lunar.lunarToSolar(y, m, d, leap, false);
|
|
return { solar: s ? `${s.y}-${pad(s.m)}-${pad(s.d)}` : null, hasLeap: !!mo || !leap, monthDays: (mo || months.find((x) => x.month === m && !x.leap) || {}).days || null, leapMonths: months.filter((x) => x.leap).map((x) => x.month) };
|
|
}
|
|
|
|
module.exports = { normalizeEvent, expand, range, nextOccurrence, dayInfo, lunarToSolar, UNITS, FREQS, COLORS, MAX_RANGE_DAYS };
|