云端 Mac 上验收 iOS 日历导出:ICS 转义与日期边界

云端 Mac 上验收 iOS 日历导出:ICS 转义与日期边界

用户把 App 导出的日程导入日历,标题里的逗号消失了,全天安排又多占一天。这类问题通常不是构建失败,而是导出器把字符串、字节和日期当成了同一种数据。你可以在云端 Mac 上用 Swift 跑固定样本,先检查 ICS 文件,再核对实际导入结果,不必等用户发截图。

先限定验收对象

本次只处理单个事件导出:标题与描述是文本,定时事件使用 UTC,全天事件使用日期。不包含重复规则、参会邀请和提醒。后面若加入这些功能,应另建样本,不能沿用“文件能打开就通过”的标准。

固定事件的 UID、DTSTAMP 和输入日期。生产环境的 UID 应随事件稳定保存,不能每次导出都重新生成;不同日历客户端对重复导入的处理不同,稳定 UID 也不等于保证自动更新。

格式检查回答“文件是否符合约定”,导入检查回答“用户看见的事件是否正确”。两者不能互相替代。

在工作目录保存下面的脚本为 calendar-check.swift。它只使用 Foundation,安装并选定 Xcode 命令行工具后即可执行。

按字节折行,而不是按字符截断

ICS 文本值需要处理反斜杠、分号、逗号和换行。先统一输入换行,再做转义,最后折行。顺序反过来,可能把续行标记也转义,或者把转义序列错误地当成正文。

折行建议以每条物理行不超过 75 个八位字节为目标,不含 CRLF。续行以一个空格开始,这个空格也占一个字节。不要用 String.count 计算容量,更不能直接截断 UTF-8 数组,否则中文与表情可能变成非法编码。

import Foundation

func escapeText(_ value: String) -> String {
    value
        .replacingOccurrences(of: "\r\n", with: "\n")
        .replacingOccurrences(of: "\r", with: "\n")
        .replacingOccurrences(of: "\\", with: "\\\\")
        .replacingOccurrences(of: ";", with: "\\;")
        .replacingOccurrences(of: ",", with: "\\,")
        .replacingOccurrences(of: "\n", with: "\\n")
}

func fold(_ value: String) -> String {
    var lines: [String] = []
    var current = ""
    var bytes = 0

    for scalar in value.unicodeScalars {
        let piece = String(scalar)
        let size = piece.utf8.count
        if bytes + size > 75 {
            lines.append(current)
            current = " "
            bytes = 1
        }
        current += piece
        bytes += size
    }

    lines.append(current)
    return lines.joined(separator: "\r\n")
}

let title = String(repeating: "项目复盘🧑🏽‍💻", count: 12)
let description = "范围,A;B\\C\r\n第二行"
let logicalLines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//MiniVPS//Calendar Check//ZH",
    "BEGIN:VEVENT",
    "UID:calendar-check-001",
    "DTSTAMP:20260929T000000Z",
    "DTSTART:20260930T010000Z",
    "DTEND:20260930T020000Z",
    "SUMMARY:" + escapeText(title),
    "DESCRIPTION:" + escapeText(description),
    "END:VEVENT",
    "END:VCALENDAR"
]

let output = logicalLines.map(fold).joined(separator: "\r\n") + "\r\n"
let physicalLines = output.components(separatedBy: "\r\n")
precondition(physicalLines.last == "")
precondition(physicalLines.dropLast().allSatisfy {
    !$0.isEmpty && $0.utf8.count <= 75
})

let withoutCRLF = output.replacingOccurrences(of: "\r\n", with: "")
precondition(!withoutCRLF.contains("\r"))
precondition(!withoutCRLF.contains("\n"))

let unfolded = output.replacingOccurrences(of: "\r\n ", with: "")
precondition(unfolded == logicalLines.joined(separator: "\r\n") + "\r\n")
precondition(escapeText(description) == "范围\\,A\\;B\\\\C\\n第二行")

try Data(output.utf8).write(
    to: URL(fileURLWithPath: "calendar-check.ics"),
    options: .atomic
)

这里按 Unicode scalar 累加,保证每条物理行都是有效 UTF-8。组合表情可能在两条物理行之间拆开,但移除续行标记后必须恢复原文。脚本中的展开一致性检查就是为了防止折行丢字或多字。它检查的是本例生成器,不是通用 ICS 解析器。

把绝对时间和全天日期分开

定时事件使用带 Z 的 UTC 值,表示确定的时间点。输入来自本地日历时,先结合用户选择的时区解析为时间点,再格式化,不能只在本地时间字符串后补一个 Z。

全天事件则不应该先转换为午夜 UTC。把上例的开始与结束属性替换为:

DTSTART;VALUE=DATE:20260930
DTEND;VALUE=DATE:20261001

这代表九月三十日一天。结束日期不包含在事件内;跨月、跨年也遵守同一规则。程序计算次日时应使用明确日历的日期运算,不要给某个时间戳直接加固定秒数。

如果产品要求“当地每天上午九点”,UTC 单次事件不够表达这种语义。需要时区标识、时区定义和重复规则的独立设计,不能靠导入客户端猜测。

用导入结果检查语义

运行脚本,再把生成文件传回本地,导入一个专门用于测试的日历:

xcrun swift calendar-check.swift
shasum -a 256 calendar-check.ics

传输后再次计算摘要,确认测试的是同一份文件。远程桌面能展示文件,并不能证明传输或内容正确。

逐项核对:中文和表情是否完整;逗号、分号与反斜杠是否仍在;描述是否恰好两行;定时事件是否持续一小时;全天事件是否只覆盖一天。至少在项目实际支持的客户端中各验收一次,不要把一个客户端的容错行为当成格式标准。

还应补充空标题、超长描述、跨月全天事件,以及不同时区显示同一 UTC 事件的样本。人工核对时间时,同时记录客户端时区,避免把正确的换算误判为偏移。

把失败变成可定位的证据

CI 中保留生成文件、输入样本标识、脚本版本与执行退出码。检查失败就停止交付,不要捕获错误后继续上传旧文件。真实事件标题和描述可能包含私人信息,回归产物只保留合成数据。

把门禁拆成三层:字节层检查 CRLF 与展开一致性;属性层检查必填字段及日期类型;语义层检查导入后的日期、持续时间和正文。新增重复事件或提醒时,明确新增哪层规则、哪份失败样本。

最后给导出器加一个最小反例:删除逗号转义、把全天结束日期改成当天,或在 UTF-8 中间截断。测试必须能抓住这些故障,才算真正保护了导出契约,而不是只验证当前实现可以运行。

常见问题

ICS 每行最多 75 个字符吗?

折行建议按 75 个八位字节计算,不是按字符数计算。中文和表情可能占多个 UTF-8 字节,续行开头的空格也计入该物理行的长度。

全天事件的 DTEND 应该填最后一天吗?

不应该。全天事件的 DTEND 是不包含在事件中的结束日期;单日事件应填写次日,并让 DTSTART 与 DTEND 都使用 VALUE=DATE。

把下一次构建放到云端工位

整台 Mac mini 独享,非虚拟机。按任务选择租期,实际可用状态以控制台实时返回为准。

立即租用云端 Mac

先核对配置,再安排任务

查看 Desk M4 的计费周期与附加项,或按上手文档准备第一次连接。