Auto backup before evolver run - 2026年 03月 06日 星期五 19:00:05 CST

This commit is contained in:
2026-03-06 19:00:05 +08:00
parent 21d20631dd
commit 6bca8bd723
91 changed files with 34892 additions and 318 deletions
+549
View File
@@ -0,0 +1,549 @@
#!/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);
});