diff --git a/app/Console/Commands/PopulateAirportTimezones.php b/app/Console/Commands/PopulateAirportTimezones.php index c2def03..dd97396 100644 --- a/app/Console/Commands/PopulateAirportTimezones.php +++ b/app/Console/Commands/PopulateAirportTimezones.php @@ -46,7 +46,7 @@ class PopulateAirportTimezones extends Command $airport->update(['timezone' => $zoneName]); $this->info("✓ {$airport->name} — {$zoneName}"); } else { - $this->warn("✗ {$airport->name} — giving up after {$attempts} attempts"); + $this->warn("✗ {$airport->name} — giving up after {$attempts} attempts".$response->body()); } } }); diff --git a/app/Http/Controllers/FlightImportController.php b/app/Http/Controllers/FlightImportController.php index 33ee42e..5c1d85d 100644 --- a/app/Http/Controllers/FlightImportController.php +++ b/app/Http/Controllers/FlightImportController.php @@ -148,7 +148,7 @@ class FlightImportController extends Controller $date = null; if ($flightToReconcile->date) { - $date = Carbon::createFromFormat('m-d-y', $flightToReconcile->date)->format('Y-m-d'); + $date = Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d'); } @@ -192,7 +192,7 @@ class FlightImportController extends Controller 'to_id' => 'required|integer|exists:airports,id', 'dep_time' => 'nullable|date_format:H:i', 'arr_time' => 'nullable|date_format:H:i', - 'duration' => 'nullable|date_format:H:i', + 'duration' => 'required|date_format:H:i', 'airline_id' => 'nullable|integer|exists:airlines,id', 'aircraft_id' => 'nullable|integer|exists:aircraft,id', 'registration' => 'nullable|string', @@ -207,10 +207,68 @@ class FlightImportController extends Controller 'dep_time.date_format' => 'Departure time must be in HH:MM format, i.e: 01:25', 'arr_time.date_format' => 'Arrival time must be in HH:MM format, i.e: 12:25', 'duration.date_format' => 'Must be in HH:MM format, e.g: 03:37', + 'duration.required' => 'A duration is required to be able to accurately calculate the arrival date', ]); } + + private function validateCsvFormat(string $path): ?string + { + $handle = fopen($path, 'r'); + + // Must have empty first line + $firstLine = fgetcsv($handle); + if (!empty(array_filter($firstLine))) { + fclose($handle); + return 'CSV must match the MyFlightRadar24 export format.'; + } + + // Validate headers + $expectedHeaders = [ + 'date', 'flight_number', 'from', 'to', 'dep_time', 'arr_time', + 'duration', 'airline', 'aircraft', 'registration', 'seat_number', + 'seat_type', 'flight_class', 'flight_reason', 'note', + 'dep_id', 'arr_id', 'airline_id', 'aircraft_id' + ]; + + $headers = array_map( + fn($h) => strtolower(trim(str_replace([' ', '"'], ['_', ''], $h))), + fgetcsv($handle) + ); + + if ($headers !== $expectedHeaders) { + fclose($handle); + return 'CSV headers do not match the expected format.'; + } + + // Validate data rows + $row = 1; + while (($data = fgetcsv($handle)) !== false) { + $row++; + + if (count($data) !== count($expectedHeaders)) { + fclose($handle); + return "Row {$row} has the wrong number of columns."; + } + + $combined = array_combine($expectedHeaders, $data); + + if (empty($combined['date']) || empty($combined['from']) || empty($combined['to'])) { + fclose($handle); + return "Row {$row} is missing a required field (date, from, or to)."; + } + + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $combined['date'])) { + fclose($handle); + return "Row {$row} has an invalid date format. Expected YYYY-MM-DD, got '{$combined['date']}'."; + } + } + + fclose($handle); + return null; + } + public function store(Request $request) { try { @@ -219,6 +277,12 @@ class FlightImportController extends Controller ]); $path = $request->file('csv')->getRealPath(); + + $validationError = $this->validateCsvFormat($path); + if ($validationError) { + return response()->json(['message' => $validationError], 422); + } + $handle = fopen($path, 'r'); fgetcsv($handle); diff --git a/resources/js/Pages/Fr24Import.vue b/resources/js/Pages/Fr24Import.vue index 2960255..eb09bc1 100644 --- a/resources/js/Pages/Fr24Import.vue +++ b/resources/js/Pages/Fr24Import.vue @@ -2,7 +2,7 @@ import MainLayout from "@/Layouts/MainLayout.vue"; import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue"; import { Head } from "@inertiajs/vue3"; -import { ref } from "vue"; +import { ref, computed} from "vue"; import {VFileInput} from "vuetify/components"; import {getCsrfToken} from "@/utils/helpers"; @@ -15,6 +15,13 @@ const errors = ref([]); const fileInput = ref | null>(null); const selectedFile = ref(null); +const blurb = computed(() => { + if (status.value === 'success') { + return `Successfully imported ${importedCount} flight${importedCount.value !== 1 ? 's' : ''}. You will just need to reconcile some mismatched airlines and airports.`; + } + return 'Import a CSV export from MyFlightRadar24. You will then be guided to reconcile any data mismatches.'; +}); + async function onFileChange(e: Event) { const input = e.target as HTMLInputElement; const file = input.files?.[0]; @@ -57,8 +64,7 @@ async function onFileChange(e: Event) {