Three bugs caused partial imports: 1. Type filter halted pagination: if a page contained only tours of the excluded type (planned vs. recorded), syncTrailWithTours returned false and the loop stopped even though later pages might have matching tours. Fixed by tracking allAlreadySynced separately - only tours already in the DB count toward early termination, not type-filtered ones. 2. continue on error never incremented page: a fetch or sync error called continue on the outer for loop, so page stayed the same and hasNewTours was never updated, causing an infinite retry on the failing page. Changed to break so a failed page aborts the current sync cycle cleanly. 3. Pagination ignored TotalPages: the loop used a fragile stop heuristic instead of the TotalPages value returned by the Komoot API. fetchTours now returns TotalPages and the loop uses it as the upper bound, with the allAlreadySynced early-exit kept as an optimization for incremental syncs. Closes #570 Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
494 lines
13 KiB
Go
494 lines
13 KiB
Go
package komoot
|
|
|
|
import (
|
|
"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/tkrajina/gpxgo/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
|
|
}
|
|
totalPages := 1
|
|
for page := 0; page < totalPages; page++ {
|
|
tours, tp, err := k.fetchTours(page)
|
|
if err != nil {
|
|
warning := fmt.Sprintf("error fetching tours from komoot (page %d): %v\n", page, err)
|
|
fmt.Print(warning)
|
|
app.Logger().Warn(warning)
|
|
break
|
|
}
|
|
totalPages = tp
|
|
|
|
allAlreadySynced, 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)
|
|
break
|
|
}
|
|
if allAlreadySynced {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
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, int, 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, 0, err
|
|
}
|
|
|
|
var data KomootToursResponse
|
|
json.Unmarshal(body, &data)
|
|
|
|
return data.Embedded.Tours, data.Page.TotalPages, 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
|
|
}
|
|
|
|
// syncTrailWithTours imports tours not yet in the DB. Returns allAlreadySynced=true
|
|
// when every tour on this page was already imported, so the caller can stop paginating
|
|
// early during incremental syncs. Tours skipped due to type filters do NOT count as
|
|
// synced - only tours already present in the DB do.
|
|
func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user string, actor string, tours []KomootTour) (bool, error) {
|
|
allAlreadySynced := true
|
|
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 false, err
|
|
}
|
|
if len(trails) != 0 {
|
|
continue
|
|
}
|
|
// Tour is not yet in the DB - we must keep paginating regardless of type filter
|
|
allAlreadySynced = false
|
|
if (tour.Type == "tour_planned" && !i.Planned) || (tour.Type == "tour_recorded" && !i.Completed) {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
trailid, err := createTrailFromTour(app, k, detailedTour, gpx, user, actor, i.Privacy)
|
|
if err != nil {
|
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
|
continue
|
|
}
|
|
err = createWaypointsFromTour(app, detailedTour, user, trailid)
|
|
if err != nil {
|
|
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err))
|
|
continue
|
|
}
|
|
|
|
}
|
|
return allAlreadySynced, nil
|
|
}
|
|
|
|
func createTrailFromTour(app core.App, k *KomootApi, detailedTour *DetailedKomootTour, gpx *filesystem.File, user string, actor string, privacy string) (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"
|
|
}
|
|
|
|
public := detailedTour.Status == "public"
|
|
if privacy == "settings" {
|
|
privacySettings := struct {
|
|
Trails string `json:"trails"`
|
|
}{}
|
|
|
|
settings, _ := app.FindFirstRecordByData("settings", "user", user)
|
|
err = settings.UnmarshalJSONField("privacy", &privacySettings)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
public = privacySettings.Trails == "public"
|
|
}
|
|
|
|
record.Load(map[string]any{
|
|
"id": trailid,
|
|
"name": detailedTour.Name,
|
|
"public": public,
|
|
"distance": detailedTour.Distance,
|
|
"elevation_gain": detailedTour.ElevationUp,
|
|
"elevation_loss": detailedTour.ElevationDown,
|
|
"duration": detailedTour.Duration,
|
|
"date": detailedTour.Date,
|
|
"external_provider": "komoot",
|
|
"external_id": strconv.Itoa(detailedTour.ID),
|
|
"lat": detailedTour.StartPoint.Lat,
|
|
"lon": detailedTour.StartPoint.Lng,
|
|
"difficulty": diffculty,
|
|
"category": categoryId,
|
|
"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 trailid, nil
|
|
}
|
|
|
|
func createWaypointsFromTour(app core.App, tour *DetailedKomootTour, user string, trailid string) error {
|
|
collection, err := app.FindCollectionByNameOrId("waypoints")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, wp := range tour.Embedded.Timeline.Embedded.Items {
|
|
photos, err := fetchWaypointPhotos(wp)
|
|
if err != nil {
|
|
return 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,
|
|
"trail": trailid,
|
|
})
|
|
|
|
if photos != nil {
|
|
record.Set("photos", photos)
|
|
}
|
|
|
|
if err := app.Save(record); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return 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.GPXPoint
|
|
|
|
for _, item := range detailedTour.Embedded.Coordinates.Items {
|
|
t := detailedTour.Date.Unix() + int64(item.T/1000)
|
|
|
|
points = append(points, gpx.GPXPoint{
|
|
Point: gpx.Point{Latitude: item.Lat, Longitude: item.Lng, Elevation: *gpx.NewNullableFloat64(item.Alt)},
|
|
Timestamp: time.Unix(t, 0)})
|
|
}
|
|
|
|
gpxData := &gpx.GPX{
|
|
Version: "1.1",
|
|
Creator: "komoot GPX Exporter",
|
|
Tracks: []gpx.GPXTrack{
|
|
{
|
|
Name: detailedTour.Name,
|
|
Segments: []gpx.GPXTrackSegment{
|
|
{
|
|
Points: points,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
gpxAsXML, err := gpxData.ToXml(gpx.ToXmlParams{Version: "1.1", Indent: true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gpxFile, err := filesystem.NewFileFromBytes(gpxAsXML, detailedTour.Name+".gpx")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gpxFile, nil
|
|
}
|