From d639cdef15f701887ec7e5755726e309d841a655 Mon Sep 17 00:00:00 2001 From: Trey T Date: Fri, 29 May 2026 10:27:20 -0500 Subject: [PATCH] History: import flights from CSV (Southwest PNR format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Import CSV…" entry to the History tab's + menu, opening a file picker → preview → save flow. - CSVFlightImporter: RFC-4180-ish quote-aware parser + format detection. Today only recognizes the Southwest PNR export schema (columns Flt No / ORG / DST / Dep Date / OPNG Flt). Returns ParsedFlight values with carrier, flight number, route, and scheduled departure. - ImportCSVView: SwiftUI .fileImporter picks a CSV from Files (iCloud Drive / On My iPhone / etc.), parses on a Task, dedupes against the existing log via FlightHistoryStore.exists(...), shows a preview with "N new · M dupes" counts, imports on confirm. - LoggedFlights created from import store the PNR in notes ("PNR: ABC123") and source "csv-import". Co-Authored-By: Claude Opus 4.7 --- Flights.xcodeproj/project.pbxproj | 8 + Flights/Services/CSVFlightImporter.swift | 184 ++++++++++++++++++ Flights/Views/HistoryView.swift | 7 + Flights/Views/ImportCSVView.swift | 232 +++++++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 Flights/Services/CSVFlightImporter.swift create mode 100644 Flights/Views/ImportCSVView.swift diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index a930c38..148063f 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -76,6 +76,8 @@ HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; }; HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */; }; HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.swift */; }; + HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1100001100000011000002 /* CSVFlightImporter.swift */; }; + HX1200001200000012000001 /* ImportCSVView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1200001200000012000002 /* ImportCSVView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -161,6 +163,8 @@ HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = ""; }; HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = ""; }; HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = ""; }; + HX1100001100000011000002 /* CSVFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFlightImporter.swift; sourceTree = ""; }; + HX1200001200000012000002 /* ImportCSVView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportCSVView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -207,6 +211,7 @@ HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */, HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */, HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */, + HX1200001200000012000002 /* ImportCSVView.swift */, AA5555555555555555555555 /* Styles */, AA6666666666666666666666 /* Components */, ); @@ -296,6 +301,7 @@ HX0600006666000066660002 /* CalendarFlightImporter.swift */, HX0700007777000077770002 /* WalletPassObserver.swift */, HX1000001000000010000002 /* AirframeMetadataService.swift */, + HX1100001100000011000002 /* CSVFlightImporter.swift */, ); path = Services; sourceTree = ""; @@ -487,6 +493,8 @@ HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */, HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */, HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */, + HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */, + HX1200001200000012000001 /* ImportCSVView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Flights/Services/CSVFlightImporter.swift b/Flights/Services/CSVFlightImporter.swift new file mode 100644 index 0000000..8dd7dd7 --- /dev/null +++ b/Flights/Services/CSVFlightImporter.swift @@ -0,0 +1,184 @@ +import Foundation + +/// Parses a flight-history CSV export into LoggedFlight candidates. +/// Today the only format we detect is Southwest's PNR-level export +/// (the one with columns like `Flt No`, `ORG`, `DST`, `Dep Date`, +/// `OPNG Flt`), which is what the user's existing log is in. Adding +/// another format is mechanically just another `Format` case + a +/// `parseSouthwest`-style mapper. +struct CSVFlightImporter { + enum Format: String, CaseIterable { + case southwest // SWA PNR export + case unknown + } + + struct ParsedFlight { + let flightDate: Date + let scheduledDeparture: Date? + let carrierIATA: String? + let carrierICAO: String? + let flightNumber: String? + let departureIATA: String + let arrivalIATA: String + let pnr: String? + } + + enum ImportError: LocalizedError { + case unsupportedFormat + case empty + case parseFailed(String) + var errorDescription: String? { + switch self { + case .unsupportedFormat: return "This CSV's column layout isn't one we know yet." + case .empty: return "The file is empty." + case .parseFailed(let s): return "Couldn't parse the file: \(s)" + } + } + } + + // MARK: - Entry + + static func parse(_ data: Data) throws -> [ParsedFlight] { + guard let text = String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .isoLatin1) else { + throw ImportError.parseFailed("not text") + } + let rows = parseRows(text) + guard let header = rows.first, rows.count > 1 else { + throw ImportError.empty + } + switch detect(header: header) { + case .southwest: + return try parseSouthwest(rows: Array(rows.dropFirst()), header: header) + case .unknown: + throw ImportError.unsupportedFormat + } + } + + static func detect(header: [String]) -> Format { + let normalized = Set(header.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }) + let swKeys: Set = ["flt no", "org", "dst", "dep date", "opng flt"] + if swKeys.isSubset(of: normalized) { + return .southwest + } + return .unknown + } + + // MARK: - Southwest mapper + + private static func parseSouthwest(rows: [[String]], header: [String]) throws -> [ParsedFlight] { + let index = Dictionary(uniqueKeysWithValues: header.enumerated().map { + ($1.trimmingCharacters(in: .whitespaces).lowercased(), $0) + }) + func col(_ row: [String], _ key: String) -> String? { + guard let i = index[key], i < row.count else { return nil } + let v = row[i].trimmingCharacters(in: .whitespaces) + return v.isEmpty ? nil : v + } + + let depFmt = DateFormatter() + depFmt.dateFormat = "MM/dd/yyyy h:mm a" + depFmt.locale = Locale(identifier: "en_US_POSIX") + depFmt.timeZone = TimeZone(identifier: "UTC") // No tz in the file — store as UTC so daily aggregation is stable. + + var out: [ParsedFlight] = [] + out.reserveCapacity(rows.count) + for row in rows { + guard let depRaw = col(row, "dep date"), + let scheduledDep = depFmt.date(from: depRaw), + let org = col(row, "org"), + let dst = col(row, "dst") + else { continue } + let flightNum = col(row, "flt no") + let pnr = col(row, "pnr no") + // OPNG Flt is "WN1484"; the leading 2 letters are the + // marketing carrier. Default to WN since every row in the + // SW export is Southwest. + let opng = col(row, "opng flt") ?? "" + let carrierIATA = String(opng.prefix(while: { $0.isLetter })) + .uppercased() + .nonEmpty() ?? "WN" + let carrierICAO: String = { + switch carrierIATA { + case "WN": return "SWA" + default: return AircraftRegistry.shared.lookup(iata: carrierIATA)?.icao ?? carrierIATA + } + }() + + // Strip the day-of so we can dedupe across the user's + // existing manual entries without worrying about UTC roll. + let day = Calendar(identifier: .gregorian).startOfDay(for: scheduledDep) + + out.append(ParsedFlight( + flightDate: day, + scheduledDeparture: scheduledDep, + carrierIATA: carrierIATA, + carrierICAO: carrierICAO, + flightNumber: flightNum, + departureIATA: org.uppercased(), + arrivalIATA: dst.uppercased(), + pnr: pnr + )) + } + return out + } + + // MARK: - CSV parser + // + // Simple state machine that handles RFC 4180 basics: quoted + // fields, embedded commas inside quotes, and "" → " escaping. + // Mac-style and Windows line endings both work because we strip + // CR before splitting on LF. + + private static func parseRows(_ text: String) -> [[String]] { + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + var rows: [[String]] = [] + var field = "" + var row: [String] = [] + var inQuotes = false + var i = normalized.startIndex + while i < normalized.endIndex { + let c = normalized[i] + if inQuotes { + if c == "\"" { + let next = normalized.index(after: i) + if next < normalized.endIndex && normalized[next] == "\"" { + field.append("\"") + i = next + } else { + inQuotes = false + } + } else { + field.append(c) + } + } else { + switch c { + case "\"": + inQuotes = true + case ",": + row.append(field) + field = "" + case "\n": + row.append(field) + rows.append(row) + row = [] + field = "" + default: + field.append(c) + } + } + i = normalized.index(after: i) + } + // Last row (file may not end in newline) + if !field.isEmpty || !row.isEmpty { + row.append(field) + rows.append(row) + } + return rows + } +} + +private extension String { + func nonEmpty() -> String? { isEmpty ? nil : self } +} diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index c7cbdee..5d5b680 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -19,6 +19,7 @@ struct HistoryView: View { @State private var showingStats = false @State private var showingMap = false @State private var showingCalendarImport = false + @State private var showingCSVImport = false @State private var showingYearInReview = false var body: some View { @@ -67,6 +68,9 @@ struct HistoryView: View { Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") } + Button { showingCSVImport = true } label: { + Label("Import CSV…", systemImage: "doc.text") + } Divider() Button { showingStats = true } label: { Label("Lifetime stats", systemImage: "chart.bar.fill") @@ -108,6 +112,9 @@ struct HistoryView: View { store: store ) } + .sheet(isPresented: $showingCSVImport) { + ImportCSVView(store: store) + } .sheet(isPresented: $showingYearInReview) { YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date())) } diff --git a/Flights/Views/ImportCSVView.swift b/Flights/Views/ImportCSVView.swift new file mode 100644 index 0000000..c84db1f --- /dev/null +++ b/Flights/Views/ImportCSVView.swift @@ -0,0 +1,232 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// File-importer-driven CSV import flow. User picks a CSV from Files +/// (iCloud Drive / On My iPhone / etc.); we detect the format, show a +/// preview with dedupe counts, and on confirm save every novel row as +/// a LoggedFlight. Dupes (same date + flight # + route) are skipped. +struct ImportCSVView: View { + let store: FlightHistoryStore + + @Environment(\.dismiss) private var dismiss + + @State private var phase: Phase = .picking + @State private var pickedURL: URL? + @State private var parsed: [CSVFlightImporter.ParsedFlight] = [] + @State private var novel: [CSVFlightImporter.ParsedFlight] = [] + @State private var skipped: Int = 0 + @State private var errorText: String? + @State private var importedCount: Int = 0 + @State private var showFilePicker = false + + enum Phase: Equatable { + case picking + case parsing + case preview + case importing + case done + case failed + } + + var body: some View { + NavigationStack { + content + .navigationTitle("Import CSV") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + if phase == .preview && !novel.isEmpty { + ToolbarItem(placement: .primaryAction) { + Button("Import \(novel.count)") { Task { await runImport() } } + } + } + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.commaSeparatedText, .text, .data], + allowsMultipleSelection: false + ) { result in + handlePicker(result) + } + .task(id: pickedURL) { + guard let pickedURL else { return } + await runParse(url: pickedURL) + } + } + } + + @ViewBuilder + private var content: some View { + switch phase { + case .picking: + VStack(spacing: 16) { + Image(systemName: "doc.text") + .font(.system(size: 56)) + .foregroundStyle(FlightTheme.textTertiary) + Text("Choose a CSV file to import") + .font(.headline) + Text("Supported: Southwest PNR export\n(columns Flt No, ORG, DST, Dep Date, OPNG Flt)") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(FlightTheme.textTertiary) + .padding(.horizontal, 32) + Button { + showFilePicker = true + } label: { + Label("Choose file…", systemImage: "folder") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(FlightTheme.accent, in: Capsule()) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .parsing: + VStack(spacing: 12) { + ProgressView() + Text("Parsing…") + .font(.subheadline) + .foregroundStyle(FlightTheme.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .preview: + previewList + + case .importing: + VStack(spacing: 12) { + ProgressView() + Text("Importing \(importedCount) / \(novel.count)…") + .font(.subheadline) + .foregroundStyle(FlightTheme.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .done: + ContentUnavailableView( + "Imported \(importedCount) flights", + systemImage: "checkmark.circle.fill", + description: Text(skipped > 0 ? "Skipped \(skipped) duplicates." : "Your log is up to date.") + ) + + case .failed: + ContentUnavailableView( + "Couldn't read this file", + systemImage: "exclamationmark.triangle.fill", + description: Text(errorText ?? "Try a different CSV.") + ) + } + } + + private var previewList: some View { + List { + Section { + HStack { + Text("\(parsed.count) rows in file") + Spacer() + Text("\(novel.count) new · \(skipped) dupes") + .foregroundStyle(FlightTheme.textTertiary) + } + .font(.subheadline) + } header: { + Text("Summary") + } + Section { + ForEach(Array(novel.prefix(50).enumerated()), id: \.offset) { _, p in + VStack(alignment: .leading, spacing: 2) { + Text("\(p.carrierIATA ?? "?")\(p.flightNumber ?? "?")") + .font(.subheadline.weight(.semibold).monospaced()) + Text("\(p.departureIATA) → \(p.arrivalIATA) · \(shortDate(p.flightDate))") + .font(.caption.monospaced()) + .foregroundStyle(FlightTheme.textSecondary) + } + } + if novel.count > 50 { + Text("…and \(novel.count - 50) more") + .font(.caption) + .foregroundStyle(FlightTheme.textTertiary) + } + } header: { + Text("Will be imported") + } + } + } + + private func handlePicker(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + pickedURL = urls.first + case .failure(let error): + errorText = error.localizedDescription + phase = .failed + } + } + + private func runParse(url: URL) async { + phase = .parsing + let didStart = url.startAccessingSecurityScopedResource() + defer { + if didStart { url.stopAccessingSecurityScopedResource() } + } + do { + let data = try Data(contentsOf: url) + let rows = try CSVFlightImporter.parse(data) + parsed = rows + var (n, s) = ([CSVFlightImporter.ParsedFlight](), 0) + for r in rows { + let label = "\(r.carrierIATA ?? "")\(r.flightNumber ?? "")" + if store.exists( + flightDate: r.flightDate, + flightLabel: label, + departureIATA: r.departureIATA, + arrivalIATA: r.arrivalIATA + ) { + s += 1 + } else { + n.append(r) + } + } + novel = n + skipped = s + phase = .preview + } catch { + errorText = error.localizedDescription + phase = .failed + } + } + + private func runImport() async { + phase = .importing + importedCount = 0 + for p in novel { + let f = LoggedFlight( + flightDate: p.flightDate, + carrierICAO: p.carrierICAO, + carrierIATA: p.carrierIATA, + flightNumber: p.flightNumber, + departureIATA: p.departureIATA, + arrivalIATA: p.arrivalIATA, + scheduledDeparture: p.scheduledDeparture, + scheduledArrival: nil, + aircraftType: nil, + registration: nil, + icao24: nil, + notes: p.pnr.map { "PNR: \($0)" }, + source: "csv-import" + ) + store.save(f) + importedCount += 1 + } + phase = .done + } + + private func shortDate(_ d: Date) -> String { + let f = DateFormatter() + f.dateFormat = "MMM d, yyyy" + return f.string(from: d) + } +}