/* Timelinize Copyright (c) 2013 Matthew Holt This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ // Package strava implements a data source for Strava account data. package strava import ( "context" "encoding/csv" "errors" "fmt" "io" "io/fs" "github.com/timelinize/timelinize/datasources/googlelocation" "github.com/timelinize/timelinize/datasources/gpx" "github.com/timelinize/timelinize/timeline" "go.uber.org/zap" ) func init() { err := timeline.RegisterDataSource(timeline.DataSource{ Name: "strava", Title: "Strava", Icon: "strava.svg", Description: "A Strava account export.", NewOptions: func() any { return new(Options) }, NewFileImporter: func() timeline.FileImporter { return new(FileImporter) }, }) if err != nil { timeline.Log.Fatal("registering data source", zap.Error(err)) } } // Options configures the data source. type Options struct { // Options specific to the location processor. googlelocation.LocationProcessingOptions } // FileImporter implements the timeline.FileImporter interface. type FileImporter struct { } // Recognize returns whether the input is supported. func (FileImporter) Recognize(_ context.Context, dirEntry timeline.DirEntry, _ timeline.RecognizeParams) (timeline.Recognition, error) { for _, expectedFile := range []string{ "activities.csv", "profile.csv", } { if _, err := dirEntry.Stat(expectedFile); err == nil { continue } return timeline.Recognition{}, nil } return timeline.Recognition{Confidence: 1}, nil } // FileImport imports data from the input. func (fi *FileImporter) FileImport(ctx context.Context, dirEntry timeline.DirEntry, params timeline.ImportParams) error { profile, err := fi.readProfile(ctx, dirEntry.FS) if err != nil { return fmt.Errorf("reading profile: %w", err) } owner := timeline.Entity{ Name: profile.FirstName + " " + profile.LastName, Attributes: []timeline.Attribute{ { Name: "strava_athlete_id", Value: profile.AthleteID, Identity: true, }, { Name: timeline.AttributeEmail, Value: profile.EmailAddress, Identifying: true, }, { Name: timeline.AttributeGender, Value: profile.Sex, }, { Name: "weight_kilograms", Value: profile.WeightKilograms, }, { Name: "city", Value: profile.City, }, { Name: "state", Value: profile.State, }, { Name: "country", Value: profile.Country, }, }, NewPicture: func(context.Context) (io.ReadCloser, error) { picFile, err := dirEntry.FS.Open("profile.jpg") if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, nil // not an error, just no picture } return nil, err } return picFile, nil }, } activitiesFile, err := dirEntry.FS.Open("activities.csv") if err != nil { return err } defer activitiesFile.Close() reader := csv.NewReader(activitiesFile) fields := make(map[string]int) for { rec, err := reader.Read() if errors.Is(err, io.EOF) { break } if err != nil { return err } // use header row to give us order-independent access to named fields // (the field order probably doesn't change but if it does we'll be ready) // (unless they rename fields, sigh) // (this also makes dealing with wide files a little less tedious, no need to count manually) if len(fields) == 0 { for i, field := range rec { // some field names are repeated (!???!?!?!?!) but I've found the second occurrences // are more precise than the first (!??!?!?), so let it overwrite prior entries I guess fields[field] = i } continue } // each activity is a collection of location points coll := &timeline.Item{ ID: rec[fields["Activity ID"]], Classification: timeline.ClassCollection, Content: timeline.ItemData{ Data: timeline.StringData(rec[fields["Activity Name"]]), }, Owner: owner, Metadata: timeline.Metadata{ "Description": rec[fields["Activity Description"]], "Activity Date": rec[fields["Activity Date"]], "Activity Name": rec[fields["Activity Name"]], "Activity Type": rec[fields["Activity Type"]], "Activity Private Note": rec[fields["Activity Private Note"]], "Activity Gear": rec[fields["Activity Gear"]], "Filename": rec[fields["Filename"]], "Athlete Weight": rec[fields["Athlete Weight"]], "Bike Weight": rec[fields["Bike Weight"]], "Elapsed Time": rec[fields["Elapsed Time"]], "Moving Time": rec[fields["Moving Time"]], "Distance": rec[fields["Distance"]], "Max Speed": rec[fields["Max Speed"]], "Average Speed": rec[fields["Average Speed"]], "Elevation Gain": rec[fields["Elevation Gain"]], "Elevation Loss": rec[fields["Elevation Loss"]], "Elevation Low": rec[fields["Elevation Low"]], "Elevation High": rec[fields["Elevation High"]], "Max Grade": rec[fields["Max Grade"]], "Average Grade": rec[fields["Average Grade"]], "Average Positive Grade": rec[fields["Average Positive Grade"]], "Average Negative Grade": rec[fields["Average Negative Grade"]], "Max Cadence": rec[fields["Max Cadence"]], "Average Cadence": rec[fields["Average Cadence"]], "Max Heart Rate": rec[fields["Max Heart Rate"]], "Average Heart Rate": rec[fields["Average Heart Rate"]], "Max Watts": rec[fields["Max Watts"]], "Average Watts": rec[fields["Average Watts"]], "Calories": rec[fields["Calories"]], "Max Temperature": rec[fields["Max Temperature"]], "Average Temperature": rec[fields["Average Temperature"]], "Relative Effort": rec[fields["Relative Effort"]], "Total Work": rec[fields["Total Work"]], "Number of Runs": rec[fields["Number of Runs"]], "Uphill Time": rec[fields["Uphill Time"]], "Downhill Time": rec[fields["Downhill Time"]], "Other Time": rec[fields["Other Time"]], "Perceived Exertion": rec[fields["Perceived Exertion"]], "Weighted Average Power": rec[fields["Weighted Average Power"]], "Power Count": rec[fields["Power Count"]], "Prefer Perceived Exertion": rec[fields["Prefer Perceived Exertion"]], "Perceived Relative Effort": rec[fields["Perceived Relative Effort"]], "Commute": rec[fields["Commute"]], "Total Weight Lifted": rec[fields["Total Weight Lifted"]], "From Upload": rec[fields["From Upload"]], "Grade Adjusted Distance": rec[fields["Grade Adjusted Distance"]], "Weather Observation Time": rec[fields["Weather Observation Time"]], "Weather Condition": rec[fields["Weather Condition"]], "Weather Temperature": rec[fields["Weather Temperature"]], "Apparent Temperature": rec[fields["Apparent Temperature"]], "Dewpoint": rec[fields["Dewpoint"]], "Humidity": rec[fields["Humidity"]], "Weather Pressure": rec[fields["Weather Pressure"]], "Wind Speed": rec[fields["Wind Speed"]], "Wind Gust": rec[fields["Wind Gust"]], "Wind Bearing": rec[fields["Wind Bearing"]], "Precipitation Intensity": rec[fields["Precipitation Intensity"]], "Sunrise Time": rec[fields["Sunrise Time"]], "Sunset Time": rec[fields["Sunset Time"]], "Moon Phase": rec[fields["Moon Phase"]], "Bike": rec[fields["Bike"]], "Gear": rec[fields["Gear"]], "Precipitation Probability": rec[fields["Precipitation Probability"]], "Precipitation Type": rec[fields["Precipitation Type"]], "Cloud Cover": rec[fields["Cloud Cover"]], "Weather Visibility": rec[fields["Weather Visibility"]], "UV Index": rec[fields["UV Index"]], "Weather Ozone": rec[fields["Weather Ozone"]], }, } // lots of numbers in this metadata could be represented as numbers coll.Metadata.StringsToSpecificType() err = fi.processActivity(ctx, dirEntry.FS, rec[fields["Filename"]], owner, coll, params) if errors.Is(err, fs.ErrNotExist) { // Strava is known to write some filenames as "#error#", indicating a bug in their export process: see issue #137 params.Log.Error("could not open activity file; likely Strava bug causing export corruption", zap.String("filename", rec[fields["Filename"]]), zap.Error(err)) } else if err != nil { return err } } return nil } func (FileImporter) processActivity(ctx context.Context, fsys fs.FS, filename string, owner timeline.Entity, coll *timeline.Item, opt timeline.ImportParams) error { dsOpt := opt.DataSourceOptions.(*Options) file, err := fsys.Open(filename) if err != nil { return err } defer file.Close() proc, err := gpx.NewProcessor(file, owner, opt, dsOpt.LocationProcessingOptions) if err != nil { return err } var position int for { g, err := proc.NextGPXGraph(ctx) if err != nil { return err } if g == nil { break } g.ToItemWithValue(timeline.RelInCollection, coll, position) opt.Pipeline <- g position++ } return nil } func (FileImporter) readProfile(ctx context.Context, fsys fs.FS) (profile, error) { profileFile, err := fsys.Open("profile.csv") if err != nil { return profile{}, err } defer profileFile.Close() reader := csv.NewReader(profileFile) var headerRow []string var profile profile for { if err := ctx.Err(); err != nil { return profile, err } rec, err := reader.Read() if err == io.EOF { break } if err != nil { return profile, err } if len(headerRow) == 0 { headerRow = rec continue } profile.AthleteID = rec[0] profile.EmailAddress = rec[1] profile.FirstName = rec[2] profile.LastName = rec[3] profile.Sex = rec[4] profile.Description = rec[5] profile.WeightKilograms = rec[6] profile.City = rec[7] profile.State = rec[8] profile.Country = rec[9] profile.HealthConsentStatus = rec[10] profile.DateOfHealthConsent = rec[11] break } return profile, nil } type profile struct { AthleteID string EmailAddress string FirstName string LastName string Sex string Description string WeightKilograms string City string State string Country string HealthConsentStatus string DateOfHealthConsent string }