package komoot import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strconv" "strings" "time" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/security" "github.com/twpayne/go-gpx" ) func SyncKomoot(app core.App) error { integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true")) if err != nil { return err } for _, i := range integrations { encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") if len(encryptionKey) == 0 { return errors.New("POCKETBASE_ENCRYPTION_KEY not set") } userId := i.GetString("user") actor, err := app.FindFirstRecordByData("activitypub_actors", "user", userId) if err != nil { warning := fmt.Sprintf("no actor found for user: %s\n", userId) fmt.Print(warning) app.Logger().Warn(warning) continue } actorId := actor.Id komootString := i.GetString("komoot") komootIntegration := KomootIntegration{ Planned: true, Completed: true, } json.Unmarshal([]byte(komootString), &komootIntegration) if !komootIntegration.Active || komootIntegration.Email == "" || komootIntegration.Password == "" { continue } k := &KomootApi{} decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey) if err != nil { warning := fmt.Sprintf("unable to decrypt password: %v\n", err) fmt.Print(warning) app.Logger().Warn(warning) continue } err = k.Login(komootIntegration.Email, string(decryptedPassword)) if err != nil { warning := fmt.Sprintf("komoot login failed: %v\n", err) fmt.Print(warning) app.Logger().Warn(warning) continue } hasNewTours := true page := 0 for hasNewTours { tours, err := k.fetchTours(page) if err != nil { warning := fmt.Sprintf("error fetching tours from komoot: %v\n", err) fmt.Print(warning) app.Logger().Warn(warning) continue } hasNewTours, err = syncTrailWithTours(app, k, komootIntegration, userId, actorId, tours) if err != nil { warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err) fmt.Print(warning) app.Logger().Warn(warning) continue } page += 1 } } return nil } type BasicAuthToken struct { Key string Value string } func (b BasicAuthToken) Apply(req *http.Request) { authStr := "Basic " + base64.StdEncoding.EncodeToString([]byte(b.Key+":"+b.Value)) req.Header.Set("Authorization", authStr) } type KomootApi struct { UserID string Token string } func (k *KomootApi) buildHeader() *BasicAuthToken { if k.UserID != "" && k.Token != "" { return &BasicAuthToken{k.UserID, k.Token} } return nil } func sendRequest(url string, auth *BasicAuthToken) ([]byte, error) { client := &http.Client{} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } if auth != nil { auth.Apply(req) } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("error sending request to komoot (%d): %s", resp.StatusCode, string(body)) } return io.ReadAll(resp.Body) } func (k *KomootApi) Login(email, password string) error { url := fmt.Sprintf("https://api.komoot.de/v006/account/email/%s/", email) body, err := sendRequest(url, &BasicAuthToken{email, password}) if err != nil { return err } var data LoginResponse json.Unmarshal(body, &data) k.UserID = data.Username k.Token = data.Password return nil } func (k *KomootApi) fetchTours(page int) ([]KomootTour, error) { currentUri := fmt.Sprintf("https://api.komoot.de/v007/users/%s/tours/?page=%d&sort_field=date&sort_direction=desc&limit=30", k.UserID, page) body, err := sendRequest(currentUri, k.buildHeader()) if err != nil { return nil, err } var data KomootToursResponse json.Unmarshal(body, &data) tours := data.Embedded.Tours return tours, nil } func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, error) { url := fmt.Sprintf("https://api.komoot.de/v007/tours/%d?_embedded=coordinates,way_types,surfaces,directions,participants,timeline,cover_images&directions=v2&fields=timeline&format=coordinate_array&timeline_highlights_fields=tips,recommenders&page=2", tour.ID) body, err := sendRequest(url, k.buildHeader()) if err != nil { return nil, err } var data *DetailedKomootTour json.Unmarshal(body, &data) return data, nil } func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user string, actor string, tours []KomootTour) (bool, error) { hasNewTours := false for _, tour := range tours { trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(tour.ID))}) if err != nil { return hasNewTours, err } if len(trails) != 0 || (tour.Type == "tour_planned" && !i.Planned) || (tour.Type == "tour_recorded" && !i.Completed) { continue } hasNewTours = true detailedTour, err := k.fetchDetailedTour(tour) if err != nil { app.Logger().Warn(fmt.Sprintf("Unable to fetch details for tour '%s': %v", tour.Name, err)) continue } gpx, err := generateTourGPX(detailedTour) if err != nil { app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err)) continue } wpIds, err := createWaypointsFromTour(app, detailedTour, user) if err != nil { app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err)) continue } err = createTrailFromTour(app, k, detailedTour, gpx, actor, wpIds) if err != nil { app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err)) continue } } return hasNewTours, nil } func createTrailFromTour(app core.App, k *KomootApi, detailedTour *DetailedKomootTour, gpx *filesystem.File, actor string, wpIds []string) error { trailid := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) collection, err := app.FindCollectionByNameOrId("trails") if err != nil { return err } record := core.NewRecord(collection) categoryMap := map[string]string{ "hike": "Hiking", "touringbicycle": "Biking", "mtb": "Biking", "racebike": "Biking", "jogging": "Walking", "mtb_easy": "Workout", "mtb_advanced": "Walking", "mountaineering": "Hiking", } category, _ := app.FindFirstRecordByData("categories", "name", categoryMap[detailedTour.Sport]) categoryId := "" if category != nil { categoryId = category.Id } var photos []*filesystem.File if len(detailedTour.Embedded.CoverImages.Embedded.Items) > 0 { photos, err = fetchRoutePhotos(k, detailedTour) if err != nil { return err } } else { photo, err := fetchPhoto(detailedTour.MapImage.Src, "", "") if err != nil { return err } photos = append(photos, photo) } diffculty := detailedTour.Difficulty.Grade if diffculty == "" { diffculty = "easy" } record.Load(map[string]any{ "id": trailid, "name": detailedTour.Name, "public": detailedTour.Status == "public", "distance": detailedTour.Distance, "elevation_gain": detailedTour.ElevationUp, "elevation_loss": detailedTour.ElevationDown, "duration": detailedTour.Duration / 60, "date": detailedTour.Date, "external_provider": "komoot", "external_id": strconv.Itoa(detailedTour.ID), "lat": detailedTour.StartPoint.Lat, "lon": detailedTour.StartPoint.Lng, "difficulty": diffculty, "category": categoryId, "waypoints": wpIds, "author": actor, }) if photos != nil { record.Set("photos", photos) } if gpx != nil { record.Set("gpx", gpx) } if err := app.Save(record); err != nil { return err } if detailedTour.Type == "tour_recorded" { collection, err := app.FindCollectionByNameOrId("summit_logs") if err != nil { return err } summitLogRecord := core.NewRecord(collection) summitLogRecord.Load(map[string]any{ "distance": detailedTour.Distance, "elevation_gain": detailedTour.ElevationUp, "elevation_loss": detailedTour.ElevationDown, "duration": detailedTour.Duration, "date": detailedTour.Date, "author": actor, "trail": trailid, }) if err := app.Save(summitLogRecord); err != nil { return err } } return nil } func createWaypointsFromTour(app core.App, tour *DetailedKomootTour, user string) ([]string, error) { collection, err := app.FindCollectionByNameOrId("waypoints") if err != nil { return nil, err } wpIds := make([]string, len(tour.Embedded.Timeline.Embedded.Items)) for i, wp := range tour.Embedded.Timeline.Embedded.Items { photos, err := fetchWaypointPhotos(wp) if err != nil { return nil, err } record := core.NewRecord(collection) wpDescription := "" if len(wp.Embedded.Reference.Embedded.Tips.Embedded.Items) > 0 { wpDescription = wp.Embedded.Reference.Embedded.Tips.Embedded.Items[0].Text } wpLat := wp.Embedded.Reference.StartPoint.Lat if wpLat == 0 { wpLat = tour.StartPoint.Lat } wpLon := wp.Embedded.Reference.StartPoint.Lng if wpLon == 0 { wpLon = tour.StartPoint.Lng } record.Load(map[string]any{ "name": wp.Embedded.Reference.Name, "description": wpDescription, "lat": wpLat, "lon": wpLon, "icon": "circle", "author": user, "distance_from_start": 0, }) if photos != nil { record.Set("photos", photos) } if err := app.Save(record); err != nil { return nil, err } wpIds[i] = record.Id } return wpIds, nil } func fetchRoutePhotos(k *KomootApi, tour *DetailedKomootTour) ([]*filesystem.File, error) { url := fmt.Sprintf("https://api.komoot.de/v007/tours/%d/cover_images/", tour.ID) body, err := sendRequest(url, k.buildHeader()) if err != nil { return nil, err } var data *CoverImages err = json.Unmarshal(body, &data) if err != nil { return nil, err } photos := make([]*filesystem.File, data.Page.TotalElements) for i, img := range data.Embedded.Items { photo, err := fetchPhoto(img.Src, "", "") if err != nil { return nil, err } if strings.HasSuffix(photo.Name, ".gif") { continue } photos[i] = photo //TODO: komoot photos can have location data. Maybe we should create a waypoint for those photos? } return photos, nil } func fetchWaypointPhotos(wp Item) ([]*filesystem.File, error) { photos := make([]*filesystem.File, len(wp.Embedded.Reference.Embedded.Images.Embedded.Items)) for i, img := range wp.Embedded.Reference.Embedded.Images.Embedded.Items { photo, err := fetchPhoto(img.Src, "", "") if err != nil { return nil, err } if strings.HasSuffix(photo.Name, ".gif") { continue } photos[i] = photo } return photos, nil } func fetchPhoto(url string, width string, height string) (*filesystem.File, error) { url = strings.Replace(url, "{crop}", "false", 1) url = strings.Replace(url, "{width}", width, 1) url = strings.Replace(url, "{height}", height, 1) bytes, err := sendRequest(url, nil) if err != nil { return nil, err } return filesystem.NewFileFromBytes(bytes, "photo") } func generateTourGPX(detailedTour *DetailedKomootTour) (*filesystem.File, error) { var points []*gpx.WptType for _, item := range detailedTour.Embedded.Coordinates.Items { t := detailedTour.Date.Unix() + int64(item.T/1000) points = append(points, &gpx.WptType{Lat: item.Lat, Lon: item.Lng, Ele: item.Alt, Time: time.Unix(t, 0)}) } gpx := &gpx.GPX{ Version: "1.1", Creator: "komoot GPX Exporter", Trk: []*gpx.TrkType{ { Name: detailedTour.Name, TrkSeg: []*gpx.TrkSegType{ { TrkPt: points, }, }, }, }, } var buf bytes.Buffer err := gpx.Write(&buf) if err != nil { return nil, err } gpxFile, err := filesystem.NewFileFromBytes(buf.Bytes(), detailedTour.Name+".gpx") if err != nil { return nil, err } return gpxFile, nil }