550 lines
16 KiB
JavaScript
550 lines
16 KiB
JavaScript
#!/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);
|
||
});
|