#!/usr/bin/env node /** * 日历提醒系统 - Node.js 版本 * 从备忘文件同步提醒事项到 CalDAV 日历 */ import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; import ICAL from 'ical.js'; import https from 'https'; // 配置 const CONFIG_FILE = path.join(process.env.HOME, '.openclaw/cron/scripts/calendar-reminder-config.json'); // 日志 function log(level, message) { const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); console.log(`${timestamp} - ${level} - ${message}`); } // 加载配置 function loadConfig() { const configPath = CONFIG_FILE.replace('~', process.env.HOME); return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } // 解析提醒文件 function parseReminderFile(filePath) { const fullPath = filePath.replace('~', process.env.HOME); if (!fs.existsSync(fullPath)) { log('WARNING', `提醒文件不存在: ${filePath}`); return { periodic: [], single: [] }; } const content = fs.readFileSync(fullPath, 'utf-8'); return parseReminders(content); } function parseReminders(content) { const result = { periodic: [], single: [] }; const lines = content.split('\n'); let currentSection = null; for (const line of lines) { const trimmed = line.trim(); if (trimmed.includes('# 周期性提醒')) { currentSection = 'periodic'; continue; } else if (trimmed.includes('# 单次提醒')) { currentSection = 'single'; continue; } if (trimmed.startsWith('- [ ]')) { const item = trimmed.slice(5).trim(); if (currentSection === 'periodic') { const parsed = parsePeriodicItem(item); if (parsed) result.periodic.push(parsed); } else if (currentSection === 'single') { const parsed = parseSingleItem(item); if (parsed) result.single.push(parsed); } } } return result; } function parsePeriodicItem(item) { // 每3年02-01:内容(下次:2027-02-01) let match = item.match(/每(\d+)年(\d{1,2})月(\d{1,2})日[::]\s*(.+?)(?:\s*(下次[::]\s*(\d{4})-(\d{1,2})-(\d{1,2})))?$/); if (match) { const result = { type: 'yearly_n', interval: parseInt(match[1]), month: parseInt(match[2]), day: parseInt(match[3]), content: match[4].trim() }; if (match[5]) { result.nextYear = parseInt(match[5]); result.nextMonth = parseInt(match[6]); result.nextDay = parseInt(match[7]); } return result; } // 每年2月1日 或 每年02-01 match = item.match(/每年(\d{1,2})月(\d{1,2})日[::]\s*(.+)$/); if (match) { return { type: 'yearly', month: parseInt(match[1]), day: parseInt(match[2]), content: match[3].trim() }; } match = item.match(/每年(\d{1,2})-(\d{1,2})[::]\s*(.+)$/); if (match) { return { type: 'yearly', month: parseInt(match[1]), day: parseInt(match[2]), content: match[3].trim() }; } // 每半年 2月1日/8月1日 或 02-01/08-01 match = item.match(/每半年(\d{1,2})月(\d{1,2})日\/(\d{1,2})月(\d{1,2})日[::]\s*(.+)$/); if (match) { return { type: 'half_yearly', dates: [ { month: parseInt(match[1]), day: parseInt(match[2]) }, { month: parseInt(match[3]), day: parseInt(match[4]) } ], content: match[5].trim() }; } match = item.match(/每半年(\d{1,2})-(\d{1,2})\/(\d{1,2})-(\d{1,2})[::]\s*(.+)$/); if (match) { return { type: 'half_yearly', dates: [ { month: parseInt(match[1]), day: parseInt(match[2]) }, { month: parseInt(match[3]), day: parseInt(match[4]) } ], content: match[5].trim() }; } // 每年(双日期,实际上是每半年) match = item.match(/每年(\d{1,2})月(\d{1,2})日\/(\d{1,2})月(\d{1,2})日[::]\s*(.+)$/); if (match) { return { type: 'half_yearly', dates: [ { month: parseInt(match[1]), day: parseInt(match[2]) }, { month: parseInt(match[3]), day: parseInt(match[4]) } ], content: match[5].trim() }; } // 每月 match = item.match(/每月(\d{1,2})日?[::]\s*(.+)$/); if (match) { return { type: 'monthly', day: parseInt(match[1]), content: match[2].trim() }; } // 每周 const dayMap = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7 }; match = item.match(/每周([一二三四五六日]|[1-7])[::]\s*(.+)$/); if (match) { const weekday = dayMap[match[1]] || parseInt(match[1]); return { type: 'weekly', weekday, content: match[2].trim() }; } log('WARNING', `无法解析周期性提醒: ${item}`); return null; } function parseSingleItem(item) { const match = item.match(/(\d{4})-(\d{1,2})-(\d{1,2})[::]\s*(.+)$/); if (match) { return { type: 'single', year: parseInt(match[1]), month: parseInt(match[2]), day: parseInt(match[3]), content: match[4].trim() }; } log('WARNING', `无法解析单次提醒: ${item}`); return null; } // 计算下次提醒时间 function getNextOccurrence(reminder, today = new Date()) { const results = []; switch (reminder.type) { case 'yearly': results.push(nextYearly(reminder.month, reminder.day, today)); break; case 'yearly_n': results.push(nextYearlyN(reminder, today)); break; case 'half_yearly': for (const d of reminder.dates) { results.push(nextYearly(d.month, d.day, today)); } break; case 'monthly': results.push(nextMonthly(reminder.day, today)); break; case 'weekly': results.push(nextWeekly(reminder.weekday, today)); break; case 'single': results.push(new Date(reminder.year, reminder.month - 1, reminder.day)); break; } return results.filter(d => d !== null); } function nextYearly(month, day, today) { try { const date = new Date(today.getFullYear(), month - 1, day); if (date < today) { return new Date(today.getFullYear() + 1, month - 1, day); } return date; } catch (e) { log('ERROR', `无效日期: ${month}月${day}日`); return null; } } function nextYearlyN(reminder, today) { if (reminder.nextYear) { const date = new Date(reminder.nextYear, reminder.nextMonth - 1, reminder.nextDay); if (date >= today) return date; } try { const date = new Date(today.getFullYear(), reminder.month - 1, reminder.day); if (date < today) { return new Date(today.getFullYear() + reminder.interval, reminder.month - 1, reminder.day); } return date; } catch (e) { log('ERROR', `无效日期: ${reminder.month}月${reminder.day}日`); return null; } } function nextMonthly(day, today) { const year = today.getFullYear(); const month = today.getMonth(); const lastDayOfMonth = new Date(year, month + 1, 0).getDate(); const actualDay = Math.min(day, lastDayOfMonth); let date = new Date(year, month, actualDay); if (date < today) { const nextMonth = month === 11 ? 0 : month + 1; const nextYear = month === 11 ? year + 1 : year; const lastDayOfNextMonth = new Date(nextYear, nextMonth + 1, 0).getDate(); date = new Date(nextYear, nextMonth, Math.min(day, lastDayOfNextMonth)); } return date; } function nextWeekly(weekday, today) { // weekday: 1=周一, 7=周日 const jsWeekday = weekday - 1; // JS: 0=周一, 6=周日 const currentDay = today.getDay(); const currentJsDay = currentDay === 0 ? 6 : currentDay - 1; let daysAhead = jsWeekday - currentJsDay; if (daysAhead <= 0) daysAhead += 7; const nextDate = new Date(today); nextDate.setDate(today.getDate() + daysAhead); return nextDate; } // CalDAV 操作 class CalDAVClient { constructor(url, user, password) { this.url = url; this.user = user; this.password = password; this.existingEvents = new Set(); // 存储已有事件的标识 } async connect() { try { // 获取日历事件列表 const response = await this.request('REPORT', ` `); // 解析已有事件 this.parseExistingEvents(response); log('INFO', `成功连接到 CalDAV 日历,已有 ${this.existingEvents.size} 个事件`); return true; } catch (e) { log('ERROR', `连接 CalDAV 失败: ${e.message}`); return false; } } parseExistingEvents(xmlResponse) { // 从 XML 响应中提取 VEVENT 并解析 const vevents = xmlResponse.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/gi) || []; for (const vevent of vevents) { // 提取 SUMMARY 和 DTSTART const summaryMatch = vevent.match(/SUMMARY[^:]*:(.+)/i); const dtstartMatch = vevent.match(/DTSTART[^:]*:([0-9T]+)/i); if (summaryMatch && dtstartMatch) { let summary = summaryMatch[1].trim(); // 移除可能的换行和特殊字符 summary = summary.replace(/\\n/g, ' ').replace(/\r?\n/g, ' ').trim(); const dateStr = dtstartMatch[1].slice(0, 8); // YYYYMMDD // 用 summary + date 作为唯一标识 const key = `${summary}|${dateStr}`; this.existingEvents.add(key); } } } async request(method, body) { return new Promise((resolve, reject) => { const url = new URL(this.url); const options = { hostname: url.hostname, port: url.port || 443, path: url.pathname, method: method || 'GET', headers: { 'Authorization': 'Basic ' + Buffer.from(`${this.user}:${this.password}`).toString('base64'), 'Content-Type': 'application/xml; charset=utf-8', 'Depth': '1' }, rejectUnauthorized: false // 禁用 SSL 验证 }; const req = https.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(data); } else { reject(new Error(`HTTP ${res.statusCode}: ${data}`)); } }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } eventExists(content, reminderTime) { // 使用本地时间格式化日期,避免时区问题 const year = reminderTime.getFullYear(); const month = String(reminderTime.getMonth() + 1).padStart(2, '0'); const day = String(reminderTime.getDate()).padStart(2, '0'); const dateStr = `${year}${month}${day}`; // YYYYMMDD const key = `${content}|${dateStr}`; return this.existingEvents.has(key); } async createEvent(reminderTime, content, alarmMinutes = 0) { try { const vcalendar = new ICAL.Component(['vcalendar', [], []]); vcalendar.addPropertyWithValue('prodid', '-//Calendar Reminder//EN'); vcalendar.addPropertyWithValue('version', '2.0'); const vevent = new ICAL.Component('vevent'); const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}@calendar-reminder`; vevent.addPropertyWithValue('uid', uid); vevent.addPropertyWithValue('dtstart', ICAL.Time.fromJSDate(reminderTime, false)); vevent.addPropertyWithValue('dtend', ICAL.Time.fromJSDate(new Date(reminderTime.getTime() + 3600000), false)); vevent.addPropertyWithValue('summary', content); vevent.addPropertyWithValue('dtstamp', ICAL.Time.now()); // 添加提醒 const valarm = new ICAL.Component('valarm'); valarm.addPropertyWithValue('action', 'DISPLAY'); valarm.addPropertyWithValue('description', content); valarm.addPropertyWithValue('trigger', ICAL.Duration.fromSeconds(-alarmMinutes * 60)); vevent.addSubcomponent(valarm); vcalendar.addSubcomponent(vevent); const icalData = vcalendar.toString(); // 使用 PUT 创建事件 const eventUrl = new URL(this.url); eventUrl.pathname = eventUrl.pathname.replace(/\/$/, '') + '/' + uid + '.ics'; await this.putEvent(eventUrl.toString(), icalData); log('INFO', `创建事件: ${content} @ ${reminderTime.toISOString().slice(0, 10)}`); return true; } catch (e) { log('ERROR', `创建事件失败: ${e.message}`); return false; } } async putEvent(url, icalData) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const options = { hostname: urlObj.hostname, port: urlObj.port || 443, path: urlObj.pathname, method: 'PUT', headers: { 'Authorization': 'Basic ' + Buffer.from(`${this.user}:${this.password}`).toString('base64'), 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Length': Buffer.byteLength(icalData) }, rejectUnauthorized: false }; const req = https.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(data); } else { reject(new Error(`HTTP ${res.statusCode}: ${data}`)); } }); }); req.on('error', reject); req.write(icalData); req.end(); }); } } // NAS 同步 function syncFromNAS(source, target, exclude) { try { const cmd = `rclone sync "${source}" "${target.replace('~', process.env.HOME)}" --exclude "${exclude}"`; log('INFO', `从 NAS 同步: ${cmd}`); execSync(cmd, { stdio: 'pipe', timeout: 300000 }); log('INFO', '从 NAS 同步成功'); return true; } catch (e) { log('ERROR', `从 NAS 同步失败: ${e.message}`); return false; } } function syncToNAS(source, target, exclude) { try { const cmd = `rclone sync "${source.replace('~', process.env.HOME)}" "${target}" --exclude "${exclude}"`; log('INFO', `同步到 NAS: ${cmd}`); execSync(cmd, { stdio: 'pipe', timeout: 300000 }); log('INFO', '同步到 NAS 成功'); return true; } catch (e) { log('ERROR', `同步到 NAS 失败: ${e.message}`); return false; } } // 主函数 async function main() { log('INFO', '============================================================'); log('INFO', '日历提醒系统启动 (Node.js)'); log('INFO', '============================================================'); const config = loadConfig(); const today = new Date(); today.setHours(0, 0, 0, 0); // 1. 从 NAS 同步 if (config.rclone_source) { // syncFromNAS(config.rclone_source, config.rclone_target, config.rclone_exclude); } // 2. 解析提醒文件 const reminders = parseReminderFile(config.reminder_file); log('INFO', `解析到 ${reminders.periodic.length} 个周期性提醒`); log('INFO', `解析到 ${reminders.single.length} 个单次提醒`); // 3. 连接 CalDAV const caldav = new CalDAVClient(config.caldav_url, config.caldav_user, config.caldav_password); if (!await caldav.connect()) { log('ERROR', '无法连接到 CalDAV,请检查配置'); return; } const advanceDays = config.advance_days || [7, 3, 0]; // 4. 处理周期性提醒 for (const reminder of reminders.periodic) { const nextTimes = getNextOccurrence(reminder, today); for (const nextTime of nextTimes) { if (!nextTime) continue; const content = reminder.content; for (const days of advanceDays) { const reminderTime = new Date(nextTime); reminderTime.setDate(reminderTime.getDate() - days); if (reminderTime >= today) { const eventTitle = `[提前${days}天] ${content}`; if (!caldav.eventExists(eventTitle, reminderTime)) { await caldav.createEvent(reminderTime, eventTitle, 0); } } } } } // 5. 处理单次提醒 for (const reminder of reminders.single) { const reminderDate = new Date(reminder.year, reminder.month - 1, reminder.day); const content = reminder.content; for (const days of advanceDays) { const reminderTime = new Date(reminderDate); reminderTime.setDate(reminderTime.getDate() - days); if (reminderTime >= today) { const eventTitle = `[提前${days}天] ${content}`; if (!caldav.eventExists(eventTitle, reminderTime)) { await caldav.createEvent(reminderTime, eventTitle, 0); } } } } // 6. 同步回 NAS if (config.rclone_source) { // syncToNAS(config.rclone_target, config.rclone_source, config.rclone_exclude); } log('INFO', '日历提醒系统完成'); } main().catch(e => { log('ERROR', `程序异常: ${e.message}`); process.exit(1); });