Files
iPod-export/Sources/IPodSyncKit/FileNaming.swift
T
thatguygriffandClaude Opus 5 7b9d16e442 Add Swift app for iPod contacts and calendar export
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
2026-08-08 20:21:09 -03:00

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)
}
}