要做日历同步,就绕不开 iCalendar 标准。RFC 5545 定义了日历数据的交换格式,几乎所有日历客户端都支持 .ics 文件导入导出。这篇记录我解析和生成 iCalendar 数据的过程。
iCalendar 格式概述
一个 .ics 文件的基本结构:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//My Calendar//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:event-001@mycalendar.com
DTSTART:20220901T090000Z
DTEND:20220901T100000Z
SUMMARY:团队周会
DESCRIPTION:每周一上午的例行会议
LOCATION:会议室A
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:event-002@mycalendar.com
DTSTART;VALUE=DATE:20220915
DTEND;VALUE=DATE:20220916
SUMMARY:中秋节
TRANSP:TRANSPARENT
END:VEVENT
END:VCALENDAR
核心概念:
- VCALENDAR — 顶层容器,包含若干 component
- VEVENT — 事件
- VTODO — 待办事项
- VJOURNAL — 日志条目(很少用)
- VTIMEZONE — 时区定义
每个属性一行,格式是 PROPERTY;PARAM1=VALUE1:value。超长行通过以空格或 Tab 开头的续行来折叠(RFC 规定每行不超过 75 字节)。
关键属性
VEVENT 中最常用的属性:
| 属性 | 含义 | 备注 |
|---|---|---|
| UID | 全局唯一标识 | 必须,用于同步识别 |
| DTSTART | 开始时间 | 可以是 DATE 或 DATETIME |
| DTEND | 结束时间 | 与 DURATION 二选一 |
| DURATION | 持续时间 | 如 PT1H30M |
| SUMMARY | 标题 | |
| DESCRIPTION | 描述 | 支持换行(\n) |
| LOCATION | 地点 | |
| STATUS | 状态 | TENTATIVE/CONFIRMED/CANCELLED |
| RRULE | 重复规则 | 见上一篇 |
| EXDATE | 排除日期 | 重复事件中跳过的日期 |
| ATTENDEE | 参与者 | mailto:user@example.com |
| ORGANIZER | 组织者 | |
| VALARM | 提醒 | 内嵌的 component |
时间格式有三种形式:
20220901T090000Z— UTC 时间20220901T090000— 浮动本地时间DTSTART;TZID=Asia/Shanghai:20220901T170000— 带时区
Java 解析 iCal 文件(ical4j)
我们用 ical4j 库来解析 .ics 数据。Maven 依赖:
<dependency>
<groupId>org.mnode.ical4j</groupId>
<artifactId>ical4j</artifactId>
<version>3.2.5</version>
</dependency>
解析代码:
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.model.*;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.property.*;
public class ICalParser {
public List<EventDTO> parseIcs(String icsContent) throws Exception {
CalendarBuilder builder = new CalendarBuilder();
Calendar calendar = builder.build(new StringReader(icsContent));
List<EventDTO> events = new ArrayList<>();
for (Component comp : calendar.getComponents(Component.VEVENT)) {
VEvent vevent = (VEvent) comp;
EventDTO dto = new EventDTO();
dto.setUid(vevent.getUid().getValue());
dto.setSummary(vevent.getSummary() != null
? vevent.getSummary().getValue() : "");
dto.setDescription(vevent.getDescription() != null
? vevent.getDescription().getValue() : "");
dto.setLocation(vevent.getLocation() != null
? vevent.getLocation().getValue() : "");
// 处理开始时间
DtStart dtStart = vevent.getStartDate();
if (dtStart != null) {
dto.setStartTime(dtStart.getDate());
dto.setAllDay(!(dtStart.getDate() instanceof DateTime));
// 时区处理
Parameter tzid = dtStart.getParameter(Parameter.TZID);
if (tzid != null) {
dto.setTimezone(tzid.getValue());
}
}
// 处理结束时间
DtEnd dtEnd = vevent.getEndDate();
if (dtEnd != null) {
dto.setEndTime(dtEnd.getDate());
} else {
// 没有 DTEND,尝试 DURATION
Duration duration = vevent.getDuration();
if (duration != null) {
long millis = duration.getDuration().getSeconds() * 1000;
dto.setEndTime(new Date(
dtStart.getDate().getTime() + millis));
}
}
// 重复规则
Property rrule = vevent.getProperty(Property.RRULE);
if (rrule != null) {
dto.setRrule(rrule.getValue());
}
events.add(dto);
}
return events;
}
}
生成 .ics 文件
反过来,我们也需要从数据库事件生成 iCalendar 格式。这里没用 ical4j 的 model API(太繁琐了),直接拼字符串更快:
public class ICalGenerator {
public String generateIcs(List<EventDTO> events, String calendarName) {
StringBuilder sb = new StringBuilder();
sb.append("BEGIN:VCALENDAR\r\n");
sb.append("VERSION:2.0\r\n");
sb.append("PRODID:-//MyCalendar//v1.0//EN\r\n");
sb.append("CALSCALE:GREGORIAN\r\n");
sb.append("X-WR-CALNAME:").append(calendarName).append("\r\n");
for (EventDTO event : events) {
sb.append("BEGIN:VEVENT\r\n");
sb.append("UID:").append(event.getUid()).append("\r\n");
if (event.isAllDay()) {
sb.append("DTSTART;VALUE=DATE:")
.append(formatDate(event.getStartTime()))
.append("\r\n");
sb.append("DTEND;VALUE=DATE:")
.append(formatDate(event.getEndTime()))
.append("\r\n");
} else {
if (event.getTimezone() != null) {
sb.append("DTSTART;TZID=")
.append(event.getTimezone()).append(":")
.append(formatDateTime(event.getStartTime()))
.append("\r\n");
sb.append("DTEND;TZID=")
.append(event.getTimezone()).append(":")
.append(formatDateTime(event.getEndTime()))
.append("\r\n");
} else {
sb.append("DTSTART:")
.append(formatDateTimeUtc(event.getStartTime()))
.append("\r\n");
sb.append("DTEND:")
.append(formatDateTimeUtc(event.getEndTime()))
.append("\r\n");
}
}
sb.append("SUMMARY:").append(escapeText(event.getSummary()))
.append("\r\n");
if (event.getDescription() != null
&& !event.getDescription().isEmpty()) {
sb.append("DESCRIPTION:")
.append(escapeText(event.getDescription()))
.append("\r\n");
}
if (event.getRrule() != null) {
sb.append("RRULE:").append(event.getRrule())
.append("\r\n");
}
sb.append("END:VEVENT\r\n");
}
sb.append("END:VCALENDAR\r\n");
return sb.toString();
}
private String escapeText(String text) {
return text.replace("\\", "\\\\")
.replace(",", "\\,")
.replace(";", "\\;")
.replace("\n", "\\n");
}
}
一些坑
-
行折叠。RFC 5545 要求每行不超过 75 字节。解析时遇到以空格/Tab开头的行要和上一行合并;生成时长行要主动拆分。很多实现这个做得不对,导致互操作问题
-
CRLF。标准要求用
\r\n换行。有些客户端只发\n,解析时最好兼容 -
转义字符。文本属性中逗号、分号、反斜杠需要转义,换行用
\n -
全天事件的 DTEND。全天事件的 DTEND 是"排他"的。比如 9月1日一天的事件,DTEND 应该是 9月2日,不是9月1日。这个容易搞反
-
VTIMEZONE 组件。理论上带 TZID 的时间都应该在 VCALENDAR 中包含对应的 VTIMEZONE 定义。但实际上很多客户端不包含,直接用 IANA 时区名(如 Asia/Shanghai),需要做兼容
iCalendar 格式看起来简单,实际上细节非常多。好在 ical4j 帮我们处理了大部分解析问题,生成端控制好格式就行。