日历系统开发(三):iCalendar(RFC 5545)协议解析

要做日历同步,就绕不开 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");
    }
}

一些坑

  1. 行折叠。RFC 5545 要求每行不超过 75 字节。解析时遇到以空格/Tab开头的行要和上一行合并;生成时长行要主动拆分。很多实现这个做得不对,导致互操作问题

  2. CRLF。标准要求用 \r\n 换行。有些客户端只发 \n,解析时最好兼容

  3. 转义字符。文本属性中逗号、分号、反斜杠需要转义,换行用 \n

  4. 全天事件的 DTEND。全天事件的 DTEND 是"排他"的。比如 9月1日一天的事件,DTEND 应该是 9月2日,不是9月1日。这个容易搞反

  5. VTIMEZONE 组件。理论上带 TZID 的时间都应该在 VCALENDAR 中包含对应的 VTIMEZONE 定义。但实际上很多客户端不包含,直接用 IANA 时区名(如 Asia/Shanghai),需要做兼容

iCalendar 格式看起来简单,实际上细节非常多。好在 ical4j 帮我们处理了大部分解析问题,生成端控制好格式就行。