This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw/cron/scripts/calendar-reminder.mjs
T

550 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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', `<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop xmlns:d="DAV:">
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT" />
</c:comp-filter>
</c:filter>
</c:calendar-query>`);
// 解析已有事件
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);
});