Files

184 lines
14 KiB
JavaScript
Raw Permalink Normal View History

'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 };