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 */; };
|
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; };
|
||||||
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */; };
|
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */; };
|
||||||
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -161,6 +163,8 @@
|
|||||||
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -207,6 +211,7 @@
|
|||||||
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */,
|
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */,
|
||||||
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
|
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
|
||||||
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
|
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
|
||||||
|
HX1200001200000012000002 /* ImportCSVView.swift */,
|
||||||
AA5555555555555555555555 /* Styles */,
|
AA5555555555555555555555 /* Styles */,
|
||||||
AA6666666666666666666666 /* Components */,
|
AA6666666666666666666666 /* Components */,
|
||||||
);
|
);
|
||||||
@@ -296,6 +301,7 @@
|
|||||||
HX0600006666000066660002 /* CalendarFlightImporter.swift */,
|
HX0600006666000066660002 /* CalendarFlightImporter.swift */,
|
||||||
HX0700007777000077770002 /* WalletPassObserver.swift */,
|
HX0700007777000077770002 /* WalletPassObserver.swift */,
|
||||||
HX1000001000000010000002 /* AirframeMetadataService.swift */,
|
HX1000001000000010000002 /* AirframeMetadataService.swift */,
|
||||||
|
HX1100001100000011000002 /* CSVFlightImporter.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -487,6 +493,8 @@
|
|||||||
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */,
|
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */,
|
||||||
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */,
|
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */,
|
||||||
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
|
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
|
||||||
|
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */,
|
||||||
|
HX1200001200000012000001 /* ImportCSVView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 showingStats = false
|
||||||
@State private var showingMap = false
|
@State private var showingMap = false
|
||||||
@State private var showingCalendarImport = false
|
@State private var showingCalendarImport = false
|
||||||
|
@State private var showingCSVImport = false
|
||||||
@State private var showingYearInReview = false
|
@State private var showingYearInReview = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -67,6 +68,9 @@ struct HistoryView: View {
|
|||||||
Button { showingCalendarImport = true } label: {
|
Button { showingCalendarImport = true } label: {
|
||||||
Label("Scan Calendar", systemImage: "calendar")
|
Label("Scan Calendar", systemImage: "calendar")
|
||||||
}
|
}
|
||||||
|
Button { showingCSVImport = true } label: {
|
||||||
|
Label("Import CSV…", systemImage: "doc.text")
|
||||||
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button { showingStats = true } label: {
|
Button { showingStats = true } label: {
|
||||||
Label("Lifetime stats", systemImage: "chart.bar.fill")
|
Label("Lifetime stats", systemImage: "chart.bar.fill")
|
||||||
@@ -108,6 +112,9 @@ struct HistoryView: View {
|
|||||||
store: store
|
store: store
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingCSVImport) {
|
||||||
|
ImportCSVView(store: store)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingYearInReview) {
|
.sheet(isPresented: $showingYearInReview) {
|
||||||
YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date()))
|
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