日历格式解析

# 日历格式解析 **iCalendar**(通常扩展名为 `.ics`)是一种通用的日历数据交换标准(RFC 5545)。它本质上是一个**纯文本文件**,几乎所有的日历应用(Apple Calendar, Google Calendar, Outlook 等)都支持这种格式。 理解它的格式就像理解“键值对”:每一行通常包含一个属性名(Key)和一个值(Value),中间用冒号分隔。 下面是 ICS 文件的详细结构和字段意义解析: --- ### 1. 整体结构 (The Envelope) 一个标准的 ICS 文件就像一个信封,必须以 `BEGIN:VCALENDAR` 开头,以 `END:VCALENDAR` 结尾。 ```text BEGIN:VCALENDAR VERSION:2.0 PRODID:-//My Company//My Product//EN ... (这里包含具体的事件) ... END:VCALENDAR ``` * **BEGIN:VCALENDAR**: 文件的开始标记。 * **VERSION:2.0**: 版本号,目前通用的是 2.0。 * **PRODID**: 产品标识符,说明是哪个软件生成了这个文件(例如 Google Calendar 或 Outlook)。 * **METHOD**: (可选) 说明操作类型,如 `PUBLISH` (发布)、`REQUEST` (会议邀请)、`CANCEL` (取消)。 --- ### 2. 核心组件:事件 (VEVENT) 这是最常用的部分。一个日历文件可以包含多个事件,每个事件都包裹在 `BEGIN:VEVENT` 和 `END:VEVENT` 之间。 #### A. 基础信息字段 * **SUMMARY**: **(重要)** 事件的标题/摘要。 * *例:* `SUMMARY:公司周会` * **DESCRIPTION**: 事件的详细描述、备注。支持换行(在文本中用 `\n` 表示)。 * *例:* `DESCRIPTION:请带上笔记本电脑。\n会议室在三楼。` * **LOCATION**: 地点。 * *例:* `LOCATION:上海市黄浦区南京东路100号` * **URL**: 相关链接。 * *例:* `URL:https://zoom.us/j/123456` #### B. 时间相关字段 (最关键) * **DTSTART**: 事件开始时间。 * **DTEND**: 事件结束时间。 * **DTSTAMP**: 时间戳,表示这个 ICS 文件**生成的时间**(不是事件发生的时间)。 * **DURATION**: (可选) 如果没有结束时间,可以用时长代替。 * *例:* `DURATION:PT1H` (持续1小时) #### C. 唯一标识与状态 * **UID**: **(非常重要)** 事件的唯一标识符。 * 如果两个 ICS 文件里的 UID 相同,日历软件会认为它们是同一个事件(用于更新或覆盖)。 * *例:* `UID:20231027T090000-123456@example.com` * **STATUS**: 事件状态。 * `CONFIRMED` (已确认) * `TENTATIVE` (暂定) * `CANCELLED` (已取消) * **SEQUENCE**: 版本号。如果修改了事件,这个数字通常会增加(0, 1, 2...),用于同步时判断哪个是最新的。 --- ### 3. 高级字段详解 #### A. 时间格式的奥秘 ICS 的时间格式通常是 `YYYYMMDDTHHMMSS` (年月日T时分秒)。有三种写法: 1. **UTC 时间 (世界标准时间)**: 结尾带 `Z`。 * `DTSTART:20231027T080000Z` * *含义:* 格林威治时间 08:00。如果在中国 (UTC+8),日历会自动显示为 **16:00**。 2. **本地时间 (浮动时间)**: 不带 `Z`,也不带时区 ID。 * `DTSTART:20231027T080000` * *含义:* 无论你在哪个国家打开,都显示 08:00。通常不推荐,容易造成跨国会议混乱。 3. **指定时区时间**: 带 `TZID` 参数。 * `DTSTART;TZID=Asia/Shanghai:20231027T080000` * *含义:* 上海时间 08:00。 #### B. 重复规则 (RRULE) 这是 ICS 最复杂也是最强大的地方,用于定义“每逢周五”、“每月1号”等规则。 * **FREQ**: 频率 (`DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`). * **INTERVAL**: 间隔 (例如每 2 周)。 * **COUNT**: 重复总次数。 * **UNTIL**: 重复截止日期。 * **BYDAY**: 指定星期几 (`MO`, `TU`, `WE`, `TH`, `FR`, `SA`, `SU`)。 * *例子:* 每周一和周五重复,共 10 次。 `RRULE:FREQ=WEEKLY;COUNT=10;BYDAY=MO,FR` #### C. 提醒/闹钟 (VALARM) 这是一个嵌套在 `VEVENT` 内部的组件,用于设定提醒。 ```text BEGIN:VALARM ACTION:DISPLAY <-- 动作:弹出显示 (还有 AUDIO, EMAIL) DESCRIPTION:该开会了 <-- 提醒文字 TRIGGER:-PT15M <-- 触发时机:事件开始前(P)的(T)15分钟(15M)。负号代表“提前”。 END:VALARM ``` --- ### 4. 一个完整的 ICS 文件示例 您可以复制下面的内容保存为 `.ics` 文件,它包含了一个标题为“项目启动会”的事件,带有提醒。 ```text BEGIN:VCALENDAR VERSION:2.0 PRODID:-//JSBox//Calendar Script//CN CALSCALE:GREGORIAN METHOD:PUBLISH BEGIN:VEVENT UID:jsbox-tutorial-20251227-001 DTSTAMP:20251227T010000Z DTSTART;TZID=Asia/Shanghai:20251227T100000 DTEND;TZID=Asia/Shanghai:20251227T113000 SUMMARY:项目启动会议 DESCRIPTION:讨论Q4季度的开发计划。\n请大家准时参加。 LOCATION:第一会议室 STATUS:CONFIRMED BEGIN:VALARM TRIGGER:-PT30M ACTION:DISPLAY DESCRIPTION:会议还有30分钟开始 END:VALARM END:VEVENT END:VCALENDAR ``` ### 总结 当您在编写代码解析 ICS 时,**最少需要处理**的是: 1. 找到 `BEGIN:VEVENT`。 2. 提取 `SUMMARY` (标题)。 3. 提取 `DTSTART` (开始时间) 和 `DTEND` (结束时间)。 4. 处理 `DTSTART` 可能存在的 `TZID` (时区) 或 `Z` (UTC) 后缀,将其转换为 JavaScript 的 Date 对象。 jsbox导入 /* JSBox 脚本:直接解析 ICS 并写入系统日历 功能:读取 ICS 文件内容,解析事件,直接写入日历 */ function main() { $ui.menu({ items: ["从文件导入 (.ics)", "从剪贴板导入"], handler: function(title, idx) { if (idx === 0) { pickFile(); } else { parseAndImport($clipboard.text); } } }); } function pickFile() { $drive.open({ types: ["public.item"], // 允许选择所有类型,避免过滤掉 .ics handler: function(data) { if (data) { let content = data.string; if (content) { parseAndImport(content); } else { $ui.error("无法读取文件内容"); } } } }); } function parseAndImport(icsContent) { if (!icsContent || !icsContent.includes("BEGIN:VCALENDAR")) { $ui.error("无效的 ICS 内容"); return; } // 简单的正则表达式解析 ICS // 注意:ICS 格式很复杂,这里只提取核心信息 (标题、开始时间、结束时间、描述) const events = []; const lines = icsContent.split(/\r\n|\n|\r/); let currentEvent = null; for (let line of lines) { if (line.startsWith("BEGIN:VEVENT")) { currentEvent = {}; } else if (line.startsWith("END:VEVENT")) { if (currentEvent) events.push(currentEvent); currentEvent = null; } else if (currentEvent) { // 解析字段 if (line.startsWith("SUMMARY:")) currentEvent.title = line.substring(8); else if (line.startsWith("DESCRIPTION:")) currentEvent.notes = line.substring(12); else if (line.startsWith("LOCATION:")) currentEvent.location = line.substring(9); else if (line.startsWith("DTSTART:")) currentEvent.startDate = parseICSTime(line.substring(8)); // 处理带时区的格式 DTSTART;TZID=Asia/Shanghai:2023... else if (line.startsWith("DTSTART;")) currentEvent.startDate = parseICSTime(line.split(":")[1]); else if (line.startsWith("DTEND:")) currentEvent.endDate = parseICSTime(line.substring(6)); else if (line.startsWith("DTEND;")) currentEvent.endDate = parseICSTime(line.split(":")[1]); } } if (events.length === 0) { $ui.error("未在文件中找到事件"); return; } $ui.alert({ title: "确认导入", message: `解析到 ${events.length} 个事件,是否导入系统日历?\n(首个事件: ${events[0].title})`, actions: [ { title: "导入", handler: function() { batchCreateEvents(events); } }, { title: "取消", style: "Cancel" } ] }); } // 辅助函数:解析 ICS 时间字符串 (例如 20231027T080000Z) function parseICSTime(timeStr) { if (!timeStr) return new Date(); // 简单的格式清理 let cleanStr = timeStr.replace("Z", ""); // 提取年月日时分秒 // 格式通常是 YYYYMMDDTHHMMSS if (cleanStr.length >= 15) { let year = parseInt(cleanStr.substring(0, 4)); let month = parseInt(cleanStr.substring(4, 6)) - 1; let day = parseInt(cleanStr.substring(6, 8)); let hour = parseInt(cleanStr.substring(9, 11)); let minute = parseInt(cleanStr.substring(11, 13)); let second = parseInt(cleanStr.substring(13, 15)); return new Date(year, month, day, hour, minute, second); } else if (cleanStr.length === 8) { // 仅日期 YYYYMMDD let year = parseInt(cleanStr.substring(0, 4)); let month = parseInt(cleanStr.substring(4, 6)) - 1; let day = parseInt(cleanStr.substring(6, 8)); return new Date(year, month, day); } return new Date(); } function batchCreateEvents(events) { let successCount = 0; // 循环创建事件 // 注意:JSBox 的 $calendar.create 是异步的,但在循环中简单的调用通常可行 // 严谨做法应该用 Promise,这里为了代码简洁直接循环 events.forEach(evt => { $calendar.create({ title: evt.title || "无标题事件", startDate: evt.startDate || new Date(), endDate: evt.endDate || new Date(new Date().getTime() + 3600000), // 默认一小时 notes: evt.notes || "", location: evt.location || "", handler: function(resp) { // 这里仅仅是回调,不阻塞 } }); successCount++; }); $ui.toast(`已尝试导入 ${successCount} 个事件,请检查日历`); } main();