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
65 lines
2.3 KiB
Swift
65 lines
2.3 KiB
Swift
import Foundation
|
|
|
|
/// Produces filenames safe for an iPod's volume, and unique within one export.
|
|
///
|
|
/// Click-wheel iPods are usually FAT32 and their Contacts/Calendars browsers
|
|
/// handle non-ASCII filenames poorly, so names are folded to ASCII rather than
|
|
/// passed through. Accents fold to their base letter (`Renée` → `Renee`);
|
|
/// anything still outside the safe set is dropped.
|
|
public struct FileNameAllocator {
|
|
/// Lowercased names already handed out, so collisions can be numbered.
|
|
private var used: Set<String> = []
|
|
|
|
public static let maximumLength = 60
|
|
|
|
public init() {}
|
|
|
|
/// Returns a unique filename with `fileExtension`, based on `preferred`.
|
|
public mutating func allocate(
|
|
preferred: String,
|
|
fallback: String = "item",
|
|
fileExtension: String
|
|
) -> String {
|
|
let base = Self.sanitize(preferred, fallback: fallback)
|
|
|
|
var candidate = base
|
|
var counter = 1
|
|
while used.contains(candidate.lowercased()) {
|
|
counter += 1
|
|
let suffix = "_\(counter)"
|
|
let trimmed = String(base.prefix(Self.maximumLength - suffix.count))
|
|
candidate = trimmed + suffix
|
|
}
|
|
used.insert(candidate.lowercased())
|
|
|
|
return "\(candidate).\(fileExtension)"
|
|
}
|
|
|
|
/// Folds to ASCII, keeps `[A-Za-z0-9 ._-]`, collapses runs of whitespace.
|
|
static func sanitize(_ name: String, fallback: String) -> String {
|
|
let folded = name.folding(
|
|
options: [.diacriticInsensitive, .widthInsensitive],
|
|
locale: Locale(identifier: "en_US_POSIX")
|
|
)
|
|
|
|
let allowed = folded.map { character -> Character in
|
|
character.isASCII && (character.isLetter || character.isNumber
|
|
|| character == " " || character == "." || character == "_"
|
|
|| character == "-")
|
|
? character
|
|
: " "
|
|
}
|
|
|
|
let collapsed = String(allowed)
|
|
.split(separator: " ", omittingEmptySubsequences: true)
|
|
.joined(separator: " ")
|
|
|
|
// A name of only dots would collide with "." / ".." on the device.
|
|
let trimmed = collapsed
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: " ."))
|
|
.prefix(maximumLength)
|
|
|
|
return trimmed.isEmpty ? fallback : String(trimmed)
|
|
}
|
|
}
|