雲端 Mac 上檢核 iOS 行事曆匯出:ICS 跳脫與日期邊界

雲端 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 純量值為單位累加,確保每條實體行都是有效的 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 的計費週期與加購項目,或參考入門指南,準備首次連線。