Replaces the bash/AppleScript/Python app bundle with a SwiftUI app backed by a testable library target. The app now reads Contacts and EventKit directly rather than sanitizing files someone exported by hand, so the properties that break click-wheel firmware are never written instead of being stripped afterwards: no PHOTO, no X-, no VALARM, no VTIMEZONE, no TZID. Recurring events arrive from EventKit already expanded into occurrences, so no RRULE is emitted either - the open question from the previous version no longer applies. Sanitizing existing .vcf/.ics files is kept as a second path, and gains handling the Python lacked: vCard 2.1 quoted-printable soft line breaks, Apple's item1. property groups, nested STANDARD/DAYLIGHT components inside VTIMEZONE, and UTC times converted to local floating times. Output is laid out as Contacts/ and Calendars/ subfolders mirroring the device, one file per contact and one per calendar. Built via SwiftPM plus Scripts/bundle.sh, which assembles and ad-hoc signs the bundle. Ad-hoc signing is required rather than cosmetic: Contacts and Calendars key permissions off the code signature. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01C5X1tYo9oxAfvoiFMh1QQr
115 lines
4.4 KiB
Swift
115 lines
4.4 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import IPodSyncKit
|
|
|
|
@Suite("Content line folding and escaping")
|
|
struct ContentLineTests {
|
|
@Test("Short lines are left alone")
|
|
func shortLineUnchanged() {
|
|
#expect(ContentLine.fold("FN:Ada Lovelace") == "FN:Ada Lovelace")
|
|
}
|
|
|
|
@Test("Long lines fold to CRLF + space, within the octet limit")
|
|
func longLineFolds() {
|
|
let line = "NOTE:" + String(repeating: "a", count: 300)
|
|
let folded = ContentLine.fold(line)
|
|
|
|
#expect(folded.contains("\r\n "))
|
|
|
|
for piece in folded.components(separatedBy: "\r\n") {
|
|
#expect(piece.utf8.count <= ContentLine.octetLimit)
|
|
}
|
|
// Unfolding is lossless.
|
|
#expect(ContentLine.unfold(folded) == [line])
|
|
}
|
|
|
|
@Test("Folding never splits a multi-byte character")
|
|
func foldingRespectsUTF8Boundaries() {
|
|
// Four-byte scalars, so a naive byte split lands mid-character.
|
|
let line = "NOTE:" + String(repeating: "🎧", count: 60)
|
|
let folded = ContentLine.fold(line)
|
|
|
|
for piece in folded.components(separatedBy: "\r\n") {
|
|
#expect(piece.utf8.count <= ContentLine.octetLimit)
|
|
// A replacement character would mean we cut a scalar in half.
|
|
#expect(!piece.contains("\u{FFFD}"))
|
|
}
|
|
#expect(ContentLine.unfold(folded) == [line])
|
|
}
|
|
|
|
@Test("A single character wider than the budget is emitted rather than looping")
|
|
func oversizedCharacterTerminates() {
|
|
let line = String(repeating: "🎧", count: 2)
|
|
#expect(ContentLine.fold(line) == line)
|
|
}
|
|
|
|
@Test("TEXT values escape backslash, semicolon, comma and newline")
|
|
func escaping() {
|
|
#expect(ContentLine.escapeText("a;b,c\\d") == "a\\;b\\,c\\\\d")
|
|
#expect(ContentLine.escapeText("line1\nline2") == "line1\\nline2")
|
|
#expect(ContentLine.escapeText("crlf\r\nhere") == "crlf\\nhere")
|
|
}
|
|
|
|
@Test("Unfolding accepts CRLF, LF and bare CR")
|
|
func unfoldingLineEndings() {
|
|
#expect(ContentLine.unfold("A:1\r\nB:2") == ["A:1", "B:2"])
|
|
#expect(ContentLine.unfold("A:1\nB:2") == ["A:1", "B:2"])
|
|
#expect(ContentLine.unfold("A:1\rB:2") == ["A:1", "B:2"])
|
|
#expect(ContentLine.unfold("A:12\r\n 34") == ["A:1234"])
|
|
#expect(ContentLine.unfold("A:12\r\n\t34") == ["A:1234"])
|
|
}
|
|
|
|
@Test("Rendered documents end with CRLF")
|
|
func builderRendersCRLF() {
|
|
var builder = ContentLineBuilder()
|
|
builder.appendVerbatim("BEGIN", "VCARD")
|
|
builder.appendVerbatim("END", "VCARD")
|
|
|
|
#expect(builder.render() == "BEGIN:VCARD\r\nEND:VCARD\r\n")
|
|
}
|
|
}
|
|
|
|
@Suite("Filename allocation")
|
|
struct FileNamingTests {
|
|
@Test("Accents fold to ASCII and unsafe characters are dropped")
|
|
func sanitizing() {
|
|
var allocator = FileNameAllocator()
|
|
#expect(allocator.allocate(preferred: "Renée Fleming", fileExtension: "vcf")
|
|
== "Renee Fleming.vcf")
|
|
|
|
var other = FileNameAllocator()
|
|
#expect(other.allocate(preferred: "A/B:C*D", fileExtension: "vcf") == "A B C D.vcf")
|
|
}
|
|
|
|
@Test("Empty and dot-only names fall back")
|
|
func fallbacks() {
|
|
var allocator = FileNameAllocator()
|
|
#expect(allocator.allocate(preferred: "", fileExtension: "vcf") == "item.vcf")
|
|
#expect(allocator.allocate(preferred: "...", fallback: "contact", fileExtension: "vcf")
|
|
== "contact.vcf")
|
|
}
|
|
|
|
@Test("Repeated names are numbered")
|
|
func deduplication() {
|
|
var allocator = FileNameAllocator()
|
|
#expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith.vcf")
|
|
#expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith_2.vcf")
|
|
// Matching is case-insensitive, since the device's volume is usually FAT32.
|
|
#expect(allocator.allocate(preferred: "john smith", fileExtension: "vcf") == "john smith_3.vcf")
|
|
}
|
|
|
|
@Test("Names are capped, including their numbering suffix")
|
|
func lengthCap() {
|
|
var allocator = FileNameAllocator()
|
|
let long = String(repeating: "x", count: 200)
|
|
|
|
let first = allocator.allocate(preferred: long, fileExtension: "vcf")
|
|
let second = allocator.allocate(preferred: long, fileExtension: "vcf")
|
|
|
|
#expect(first.dropLast(4).count == FileNameAllocator.maximumLength)
|
|
#expect(second.dropLast(4).count <= FileNameAllocator.maximumLength)
|
|
#expect(second != first)
|
|
}
|
|
}
|