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