History: import flights from CSV (Southwest PNR format)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = "<group>"; };
|
||||
HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = "<group>"; };
|
||||
HX1100001100000011000002 /* CSVFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFlightImporter.swift; sourceTree = "<group>"; };
|
||||
HX1200001200000012000002 /* ImportCSVView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportCSVView.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<String> = ["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 }
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user