339 lines
8.8 KiB
Go
339 lines
8.8 KiB
Go
package flighty
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/timelinize/timelinize/internal/airports"
|
|
"github.com/timelinize/timelinize/timeline"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func init() {
|
|
err := timeline.RegisterDataSource(timeline.DataSource{
|
|
Name: "flighty",
|
|
Title: "Flighty",
|
|
Icon: "flighty.png",
|
|
Description: "A flight log from the Flighty app",
|
|
NewOptions: func() any { return new(Options) },
|
|
NewFileImporter: func() timeline.FileImporter { return new(Importer) },
|
|
})
|
|
if err != nil {
|
|
timeline.Log.Fatal("registering data source", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// Options configures the data source.
|
|
type Options struct {
|
|
// The ID of the owner entity. REQUIRED if entity is to be related in DB.
|
|
OwnerEntityID uint64 `json:"owner_entity_id"`
|
|
}
|
|
|
|
// Importer can import the data from a zip file or folder.
|
|
type Importer struct{}
|
|
|
|
var exportFilenameFormatRegex = regexp.MustCompile(`(?i)^FlightyExport-\d{4}-\d{2}-\d{2}.csv$`)
|
|
|
|
// Recognize returns whether the input is supported.
|
|
func (Importer) Recognize(_ context.Context, dirEntry timeline.DirEntry, _ timeline.RecognizeParams) (timeline.Recognition, error) {
|
|
if !exportFilenameFormatRegex.MatchString(dirEntry.Name()) {
|
|
return timeline.Recognition{}, nil
|
|
}
|
|
|
|
// TODO: Perhaps read the first line & check the headings?
|
|
|
|
return timeline.Recognition{Confidence: 1}, nil
|
|
}
|
|
|
|
// FileImport imports data from the file or folder.
|
|
func (i *Importer) FileImport(ctx context.Context, dirEntry timeline.DirEntry, params timeline.ImportParams) error {
|
|
dsOpt := params.DataSourceOptions.(*Options)
|
|
|
|
airportDB, err := airports.BuildDB()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to load airport database: %w", err)
|
|
}
|
|
|
|
csvF, err := dirEntry.FS.Open(dirEntry.Filename)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to open Flighty export: %w", err)
|
|
}
|
|
|
|
r := csv.NewReader(csvF)
|
|
|
|
headers, err := r.Read()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to read CSV headers: %w", err)
|
|
}
|
|
|
|
rowAccessor, err := checkFields(headers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
rec, err := r.Read()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("unable to parse flighty CSV: %w", err)
|
|
}
|
|
if len(rec) < len(headers) {
|
|
continue
|
|
}
|
|
|
|
f, err := parseFlightDetails(rowAccessor(rec), airportDB)
|
|
if err != nil {
|
|
// TODO: How to flag that an item import failed?
|
|
continue
|
|
}
|
|
|
|
owner := timeline.Entity{ID: dsOpt.OwnerEntityID}
|
|
|
|
journey := &timeline.Graph{}
|
|
|
|
meta := timeline.Metadata{
|
|
"Flight from IATA": f.From.IATA,
|
|
"Flight from name": f.From.Name,
|
|
"Flight to IATA": f.To.IATA,
|
|
"Flight to name": f.To.Name,
|
|
|
|
"Flight Departure Terminal": f.DepartureTerminal,
|
|
"Flight Departure Gate": f.DepartureGate,
|
|
"Flight Arrival Terminal": f.ArrivalTerminal,
|
|
"Flight Arrival Gate": f.ArrivalGate,
|
|
|
|
"Flight airline": f.Airline,
|
|
"Flight number": f.Number,
|
|
|
|
"Flight aircraft type": f.Aircraft.Type,
|
|
"Flight aircraft tail number": f.Aircraft.TailNumber,
|
|
|
|
"Flight seat number": f.Aircraft.SeatNumber,
|
|
"Flight seat type": string(f.Aircraft.SeatType),
|
|
"Flight seat cabin": string(f.Aircraft.SeatCabin),
|
|
"Flight reason type": string(f.ReasonType),
|
|
}
|
|
if f.WasDiverted() {
|
|
meta["Flight diverted to IATA"] = f.DivertedTo.IATA
|
|
meta["Flight diverted to name"] = f.DivertedTo.Name
|
|
}
|
|
|
|
meta.Clean()
|
|
|
|
coll := &timeline.Item{
|
|
ID: f.ID,
|
|
Classification: timeline.ClassCollection,
|
|
Content: timeline.ItemData{
|
|
Data: timeline.StringData(f.Description()),
|
|
MediaType: "text/markdown",
|
|
},
|
|
Timestamp: f.TakeOffTime,
|
|
Timespan: f.LandingTime,
|
|
Owner: owner,
|
|
Metadata: meta,
|
|
}
|
|
journey.Item = coll
|
|
|
|
if f.Notes != "" {
|
|
note := &timeline.Item{
|
|
Classification: timeline.ClassMessage,
|
|
Timestamp: f.LandingTime,
|
|
Owner: owner,
|
|
Content: timeline.ItemData{
|
|
Data: timeline.StringData(f.Notes),
|
|
},
|
|
}
|
|
journey.ToItem(timeline.RelAttachment, note)
|
|
}
|
|
|
|
takeOff := &timeline.Item{
|
|
Classification: timeline.ClassLocation,
|
|
Location: f.From.Location,
|
|
Timestamp: f.TakeOffTime,
|
|
Owner: owner,
|
|
}
|
|
visitTakeOff := &timeline.Entity{
|
|
Type: timeline.EntityPlace,
|
|
Name: f.From.Name,
|
|
Attributes: []timeline.Attribute{
|
|
{
|
|
Name: "coordinate",
|
|
Latitude: f.From.Location.Latitude,
|
|
Longitude: f.From.Location.Longitude,
|
|
Altitude: f.From.Location.Altitude,
|
|
Identity: true,
|
|
Metadata: timeline.Metadata{
|
|
"URL": f.From.URL,
|
|
"Exact name": exactPlaceName(f.From.Name, f.DepartureTerminal, f.DepartureGate),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
visitTakeOff.Attributes[0].Metadata.Clean()
|
|
journey.ToItemWithValue(timeline.RelInCollection, takeOff, "takeoff")
|
|
journey.ToEntity(timeline.RelVisit, visitTakeOff)
|
|
|
|
var destination airports.Info
|
|
if f.WasDiverted() {
|
|
destination = *f.DivertedTo
|
|
} else {
|
|
destination = f.To
|
|
}
|
|
|
|
landing := &timeline.Item{
|
|
Classification: timeline.ClassLocation,
|
|
Location: destination.Location,
|
|
Timestamp: f.LandingTime,
|
|
Owner: owner,
|
|
}
|
|
visitLanding := &timeline.Entity{
|
|
Type: timeline.EntityPlace,
|
|
Name: destination.Name,
|
|
Attributes: []timeline.Attribute{
|
|
{
|
|
Name: "coordinate",
|
|
Latitude: destination.Location.Latitude,
|
|
Longitude: destination.Location.Longitude,
|
|
Altitude: destination.Location.Altitude,
|
|
Identity: true,
|
|
Metadata: timeline.Metadata{
|
|
"URL": destination.URL,
|
|
"Exact name": exactPlaceName(destination.Name, f.ArrivalTerminal, f.ArrivalGate),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
visitLanding.Attributes[0].Metadata.Clean()
|
|
journey.ToItemWithValue(timeline.RelInCollection, landing, "landing")
|
|
journey.ToEntity(timeline.RelVisit, visitLanding)
|
|
|
|
params.Pipeline <- journey
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseFlightDetails(row fieldLookup, airportDB map[string]airports.Info) (flight, error) {
|
|
flightNumber, _ := strconv.ParseUint(row(flightyFields.FlightNumber), 10, 0)
|
|
|
|
details := flight{
|
|
ID: row(flightyFields.ID),
|
|
|
|
Airline: row(flightyFields.Airline),
|
|
Number: uint(flightNumber),
|
|
|
|
DepartureTerminal: row(flightyFields.DepartureTerminal),
|
|
DepartureGate: row(flightyFields.DepartureGate),
|
|
ArrivalTerminal: row(flightyFields.ArrivalTerminal),
|
|
ArrivalGate: row(flightyFields.ArrivalGate),
|
|
|
|
Aircraft: Aircraft{
|
|
Type: row(flightyFields.AircraftType),
|
|
TailNumber: row(flightyFields.AircraftTailNumber),
|
|
SeatNumber: row(flightyFields.AircraftSeatNumber),
|
|
SeatType: SeatType(row(flightyFields.AircraftSeatType)),
|
|
SeatCabin: CabinType(row(flightyFields.AircraftSeatCabin)),
|
|
},
|
|
|
|
ReasonType: ReasonType(row(flightyFields.Reason)),
|
|
Notes: row(flightyFields.Notes),
|
|
}
|
|
|
|
// Locations of airports
|
|
airport, err := extractAirport(airportDB, row, flightyFields.FromIATA)
|
|
if err != nil {
|
|
return details, err
|
|
}
|
|
details.From = *airport
|
|
|
|
airport, err = extractAirport(airportDB, row, flightyFields.ToIATA)
|
|
if err != nil {
|
|
return details, err
|
|
}
|
|
details.To = *airport
|
|
|
|
airport, err = extractAirport(airportDB, row, flightyFields.DivertedToIATA)
|
|
if err != nil {
|
|
return details, err
|
|
}
|
|
details.DivertedTo = airport
|
|
|
|
// Times
|
|
|
|
takeOffTime, err := parseAirportTime(row(flightyFields.TakeOffActual), row(flightyFields.TakeOffScheduled), details.From)
|
|
if err != nil {
|
|
return details, err
|
|
}
|
|
details.TakeOffTime = takeOffTime
|
|
|
|
landingTime, err := parseAirportTime(row(flightyFields.LandingActual), row(flightyFields.LandingScheduled), details.Destination())
|
|
if err != nil {
|
|
return details, err
|
|
}
|
|
details.LandingTime = landingTime
|
|
|
|
return details, nil
|
|
}
|
|
|
|
func extractAirport(airportDB airports.DB, row fieldLookup, field string) (*airports.Info, error) {
|
|
iata := row(field)
|
|
if iata == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
airport, ok := airportDB[iata]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown airport IATA code in '%s' field: %s", field, iata)
|
|
}
|
|
return &airport, nil
|
|
}
|
|
|
|
func parseAirportTime(actual, scheduled string, airport airports.Info) (time.Time, error) {
|
|
tz, err := time.LoadLocation(airport.Timezone)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
if t, err := time.ParseInLocation("2006-01-02T15:04", actual, tz); err == nil {
|
|
return t, nil
|
|
}
|
|
|
|
if t, err := time.ParseInLocation("2006-01-02T15:04", scheduled, tz); err == nil {
|
|
return t, nil
|
|
}
|
|
|
|
return time.Time{}, errors.New("unable to extract an actual or scheduled time")
|
|
}
|
|
|
|
func exactPlaceName(airportName, arrivalTerminal, arrivalGate string) string {
|
|
if arrivalTerminal == "" && arrivalGate == "" {
|
|
return airportName
|
|
}
|
|
|
|
out := airportName + " ("
|
|
addSep := false
|
|
if arrivalTerminal != "" {
|
|
out += "Terminal " + arrivalTerminal
|
|
addSep = true
|
|
}
|
|
if arrivalGate != "" {
|
|
if addSep {
|
|
out += ", "
|
|
}
|
|
out += "Gate " + arrivalGate
|
|
}
|
|
|
|
return out + ")"
|
|
}
|