import Foundation /// Emitting helpers shared by vCard (RFC 6350) and iCalendar (RFC 5545). /// /// Both formats use the same content-line grammar: `NAME;PARAM=value:VALUE`, /// CRLF terminators, and folding of long lines. Click-wheel iPod firmware is /// unforgiving about all three, so everything we write goes through here. public enum ContentLine { /// Maximum octets in a content line, excluding the CRLF terminator. public static let octetLimit = 75 /// Escapes a TEXT value per RFC 6350 §3.4 / RFC 5545 §3.3.11. /// /// Also applies to individual components of structured values (`N`, `ADR`), /// which are escaped the same way before being joined with `;`. public static func escapeText(_ value: String) -> String { // Swift treats CRLF as a *single* Character, so iterating without // normalising first lets a CRLF fall through unescaped and terminate // the content line early. let normalized = value .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") var out = "" out.reserveCapacity(normalized.count) for character in normalized { switch character { case "\\": out += "\\\\" case ";": out += "\\;" case ",": out += "\\," case "\n": out += "\\n" default: out.append(character) } } return out } /// Folds a content line to `octetLimit` octets, continuing with CRLF + space. /// /// Folding is defined in octets, not characters, so this splits on the UTF-8 /// representation — backing off to a leading byte so a multi-byte character /// is never cut in half. public static func fold(_ line: String) -> String { let bytes = Array(line.utf8) guard bytes.count > octetLimit else { return line } var pieces: [String] = [] var start = 0 // The first line spends all 75 octets on content; every continuation // line spends one on the leading space. var budget = octetLimit while start < bytes.count { var end = min(start + budget, bytes.count) if end < bytes.count { // 0b10xxxxxx marks a UTF-8 continuation byte; walk back to the // start of the character it belongs to. while end > start, bytes[end] & 0xC0 == 0x80 { end -= 1 } // A single character wider than the budget can't be split at // all — emit it over-length rather than looping forever. if end == start { end = min(start + budget, bytes.count) while end < bytes.count, bytes[end] & 0xC0 == 0x80 { end += 1 } } } pieces.append(String(decoding: bytes[start.. [String] { let normalized = text .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") var lines: [String] = [] for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) { if let first = line.first, first == " " || first == "\t", !lines.isEmpty { lines[lines.count - 1] += line.dropFirst() } else { lines.append(String(line)) } } return lines } } /// Accumulates content lines and renders them as a folded, CRLF-terminated document. public struct ContentLineBuilder { private var lines: [String] = [] public init() {} /// Appends `NAME:VALUE`, escaping the value as TEXT. public mutating func append(_ name: String, _ value: String) { lines.append("\(name):\(ContentLine.escapeText(value))") } /// Appends `NAME;PARAM=…:VALUE`, escaping the value as TEXT. public mutating func append( _ name: String, parameters: [(String, String)], value: String ) { let rendered = parameters.map { "\($0.0)=\($0.1)" }.joined(separator: ";") let prefix = rendered.isEmpty ? name : "\(name);\(rendered)" lines.append("\(prefix):\(ContentLine.escapeText(value))") } /// Appends a structured value whose components are escaped individually /// and joined with `;` — the shape used by `N` and `ADR`. public mutating func appendStructured(_ name: String, components: [String]) { let value = components.map(ContentLine.escapeText).joined(separator: ";") lines.append("\(name):\(value)") } /// Appends a line whose value is already in its final form (dates, UIDs, /// version numbers) and must not be escaped. public mutating func appendVerbatim(_ name: String, _ value: String) { lines.append("\(name):\(value)") } /// Appends an entire pre-built line, used when splicing in nested components. public mutating func appendRawLine(_ line: String) { lines.append(line) } public var isEmpty: Bool { lines.isEmpty } /// Folds every line and joins with CRLF, including a trailing CRLF. public func render() -> String { lines.map(ContentLine.fold).joined(separator: "\r\n") + "\r\n" } }