1
0
Fork 0
timelinize/datasources/strava/strava.go
Matthew Holt 67798d070b
strava: Permit missing activity files (close #137)
Strava bug causes missing files sometimes, indicated by filename of "#error#"
2025-10-11 12:40:48 -06:00

352 lines
11 KiB
Go

/*
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 <https://www.gnu.org/licenses/>.
*/
// 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
}