From 369e71fcece05929bc67c6111448315f01072f53 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:58:34 +0100 Subject: [PATCH] hammerhead integration added (#628) * hammerhead integration added * typo corrected * start date added, optimize some translations * new option to send trail to hammerhead * fix elevation information (was in cm) * skip empty tracks on import * update docs * remove manual user-id input * fix merge issues * remove user-id from documentation * fix hammerhead logo for light theme * fix hammerhead logo for light theme --------- Co-authored-by: Christian Beutel <> Co-authored-by: Flomp --- db/integrations/hammerhead/hammerhead.go | 910 ++++++++++++++++++ db/integrations/hammerhead/models.go | 206 ++++ db/main.go | 83 +- .../1760706161_updated_integrations.go | 41 + db/migrations/1760715417_updated_trails.go | 61 ++ docs/src/content/docs/use/integrations.md | 33 +- .../lib/assets/svgs/logos/hammerhead_dark.svg | 15 + .../assets/svgs/logos/hammerhead_white.svg | 15 + .../hammerhead_settings_modal.svelte | 122 +++ .../integrations/strava_settings_modal.svelte | 4 +- .../components/trail/trail_dropdown.svelte | 120 ++- .../components/trail/trail_send_modal.svelte | 48 + web/src/lib/i18n/locales/de.json | 14 +- web/src/lib/i18n/locales/en.json | 16 +- web/src/lib/models/api/integration_schema.ts | 17 +- web/src/lib/models/integration.ts | 14 +- web/src/lib/stores/integration_store.ts | 15 + .../integration/hammerhead/login/+server.ts | 13 + .../integration/hammerhead/upload/+server.ts | 22 + .../routes/settings/integrations/+page.svelte | 121 ++- 20 files changed, 1839 insertions(+), 51 deletions(-) create mode 100644 db/integrations/hammerhead/hammerhead.go create mode 100644 db/integrations/hammerhead/models.go create mode 100644 db/migrations/1760706161_updated_integrations.go create mode 100644 db/migrations/1760715417_updated_trails.go create mode 100644 web/src/lib/assets/svgs/logos/hammerhead_dark.svg create mode 100644 web/src/lib/assets/svgs/logos/hammerhead_white.svg create mode 100644 web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte create mode 100644 web/src/lib/components/trail/trail_send_modal.svelte create mode 100644 web/src/routes/api/v1/integration/hammerhead/login/+server.ts create mode 100644 web/src/routes/api/v1/integration/hammerhead/upload/+server.ts diff --git a/db/integrations/hammerhead/hammerhead.go b/db/integrations/hammerhead/hammerhead.go new file mode 100644 index 00000000..59f9e98c --- /dev/null +++ b/db/integrations/hammerhead/hammerhead.go @@ -0,0 +1,910 @@ +package hammerhead + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + + "math" + "os" + "slices" + "strings" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/tkrajina/gpxgo/gpx" +) + +func SyncHammerhead(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 + + hammerheadString := i.GetString("hammerhead") + hammerheadIntegration := HammerheadIntegration{ + Planned: true, + Completed: true, + } + json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration) + + if !hammerheadIntegration.Active || hammerheadIntegration.Email == "" || hammerheadIntegration.Password == "" { + continue + } + h := &HammerheadApi{} + + decryptedPassword, err := security.Decrypt(hammerheadIntegration.Password, encryptionKey) + if err != nil { + warning := fmt.Sprintf("unable to decrypt password: %v\n", err) + fmt.Print(warning) + app.Logger().Warn(warning) + continue + } + + err = h.Login(hammerheadIntegration.Email, string(decryptedPassword)) + if err != nil { + warning := fmt.Sprintf("Hammerhead login failed: %v\n", err) + fmt.Print(warning) + app.Logger().Warn(warning) + continue + } + + page := 0 + totalPages := 0 + stopped := false + + var after int64 = 0 + if hammerheadIntegration.After != "" { + t, err := time.Parse("2006-01-02", hammerheadIntegration.After) + if err != nil { + return err + } + t = t.UTC() + + after = t.Unix() + } + + if hammerheadIntegration.Planned { + page = 0 + totalPages = 0 + stopped = false + + for page <= totalPages && !stopped { + curTotalPages := totalPages + tours, curTotalPages, err := h.fetchTours(page) + if err != nil { + warning := fmt.Sprintf("error fetching tours from Hammerhead: %v\n", err) + fmt.Print(warning) + app.Logger().Warn(warning) + break + } + + if curTotalPages > totalPages { + totalPages = curTotalPages + } + + err, stopped = syncTrailWithTours(app, h, actorId, tours, after) + if err != nil { + warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err) + fmt.Print(warning) + app.Logger().Warn(warning) + break + } + + page += 1 + } + } + + if hammerheadIntegration.Completed { + page = 0 + totalPages = 0 + stopped = false + + for page <= totalPages && !stopped { + curTotalPages := totalPages + tours, curTotalPages, err := h.fetchActivities(page) + if err != nil { + warning := fmt.Sprintf("error fetching tours from Hammerhead: %v\n", err) + fmt.Print(warning) + app.Logger().Warn(warning) + break + } + + if curTotalPages > totalPages { + totalPages = curTotalPages + } + + err, stopped = syncTrailWithActivities(app, h, actorId, tours, after) + if err != nil { + warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err) + fmt.Print(warning) + app.Logger().Warn(warning) + break + } + + page += 1 + } + } + } + + return nil +} + +type BasicAuthToken struct { + Key string + Value string +} + +func (b BasicAuthToken) Apply(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+b.Value) +} + +type HammerheadApi struct { + UserID string + Token string +} + +func (h *HammerheadApi) buildHeader() *BasicAuthToken { + if h.UserID != "" && h.Token != "" { + return &BasicAuthToken{h.UserID, h.Token} + } + return nil +} + +func getToken(uri string, auth *BasicAuthToken) ([]byte, error) { + client := &http.Client{} + + var jsonStr = []byte(`{"grant_type": "password", "username": "` + auth.Key + `", "password": "` + auth.Value + `"}`) + + req, err := http.NewRequest("POST", uri, bytes.NewBuffer(jsonStr)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + 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 retrieving auth token from Hammerhead (%d): %s", resp.StatusCode, string(body)) + } + + return io.ReadAll(resp.Body) +} + +func (h *HammerheadApi) UploadActivities(e *core.RequestEvent) error { + files, err := e.FindUploadedFiles("file") + if err != nil { + if errors.Is(err, http.ErrMissingFile) { + return apis.NewBadRequestError("file field is required", err) + } + return apis.NewBadRequestError("invalid multipart payload", err) + } + + if len(files) == 0 { + return apis.NewBadRequestError("file field is required", nil) + } + + fileToUpload := files[0] + reader, err := fileToUpload.Reader.Open() + if err != nil { + return err + } + defer reader.Close() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, err := writer.CreateFormFile("file", fileToUpload.OriginalName) + if err != nil { + return err + } + + if _, err := io.Copy(part, reader); err != nil { + return err + } + + contentType := writer.FormDataContentType() + + if err := writer.Close(); err != nil { + return err + } + + currentURI := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/routes/import/file", h.UserID) + + if _, err := sendPostRequest(currentURI, &buf, contentType, h.buildHeader()); err != nil { + return err + } + + return nil +} + +func sendPostRequest(url string, body io.Reader, contentType string, auth *BasicAuthToken) ([]byte, error) { + client := &http.Client{} + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + 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 Hammerhead (%d): %s", resp.StatusCode, string(body)) + } + + return io.ReadAll(resp.Body) +} + +func sendGetRequest(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 Hammerhead (%d): %s", resp.StatusCode, string(body)) + } + + return io.ReadAll(resp.Body) +} + +func (h *HammerheadApi) Login(email, password string) error { + url := "https://dashboard.hammerhead.io/v1/auth/token" + + body, err := getToken(url, &BasicAuthToken{email, password}) + if err != nil { + return err + } + + var data LoginResponse + json.Unmarshal(body, &data) + + h.Token = data.Token + derivedUserID, err := extractUserIDFromToken(data.Token) + if err != nil { + return fmt.Errorf("unable to determine Hammerhead user id automatically: %w", err) + } + h.UserID = derivedUserID + + return nil +} + +func extractUserIDFromToken(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "", errors.New("token is not a JWT") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("unable to decode JWT payload: %w", err) + } + + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("unable to decode JWT claims: %w", err) + } + + if value, ok := claims["sub"].(string); ok && value != "" { + return value, nil + } + + return "", errors.New("no sub claim found in token") +} + +func (h *HammerheadApi) fetchActivities(page int) ([]HammerheadActivityResponse, int, error) { + + currentUri := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/activities?perPage=50&page=%d&search=&orderBy=NEWEST&ascending=true", h.UserID, page) + + body, err := sendGetRequest(currentUri, h.buildHeader()) + if err != nil { + return nil, 0, err + } + + var data HammerheadActivitiesResponse + json.Unmarshal(body, &data) + + tours := data.Tours + + return tours, data.Pages, nil +} + +func (h *HammerheadApi) fetchTours(page int) ([]HammerheadTourResponse, int, error) { + + currentUri := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/routes?perPage=50&page=%d&search=&orderBy=NEWEST&ascending=true&exclude=archive", h.UserID, page) + body, err := sendGetRequest(currentUri, h.buildHeader()) + if err != nil { + return nil, 0, err + } + + var data HammerheadToursResponse + json.Unmarshal(body, &data) + + tours := data.Data + + return tours, data.TotalPages, nil +} + +func (h *HammerheadApi) fetchDetailedActivity(tour HammerheadActivityResponse) (*HammerheadActivity, error) { + + url := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/activities/%s/details", h.UserID, tour.ID) + body, err := sendGetRequest(url, h.buildHeader()) + if err != nil { + return nil, err + } + + var data *HammerheadActivity + json.Unmarshal(body, &data) + return data, nil +} + +func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*HammerheadTour, error) { + + url := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/routes/%s", h.UserID, tour.ID) + body, err := sendGetRequest(url, h.buildHeader()) + if err != nil { + return nil, err + } + + var data *HammerheadTour + json.Unmarshal(body, &data) + return data, nil +} + +func syncTrailWithTours(app core.App, k *HammerheadApi, actor string, tours []HammerheadTourResponse, after int64) (error, bool) { + for _, tour := range tours { + + trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID}) + if err != nil { + return err, true + } + + if len(trails) != 0 { + 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 + } + + if detailedTour.CreatedAt.Unix() < after { + return nil, true + } + + if detailedTour.Distance <= 0 { + app.Logger().Warn(fmt.Sprintf("Skipping Hammerhead tour '%s' with zero distance", tour.Name)) + 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 + } + + _, err = createTrailFromTour(app, detailedTour, gpx, actor) + if err != nil { + app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err)) + continue + } + } + + return nil, false +} + +func syncTrailWithActivities(app core.App, k *HammerheadApi, actor string, tours []HammerheadActivityResponse, after int64) (error, bool) { + for _, tour := range tours { + + trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID}) + if err != nil { + return err, true + } + + if len(trails) != 0 { + continue + } + + detailedTour, err := k.fetchDetailedActivity(tour) + if err != nil { + app.Logger().Warn(fmt.Sprintf("Unable to fetch details for tour '%s': %v", tour.Name, err)) + continue + } + + if detailedTour.ActivityData.CreatedAt.Unix() < after { + return nil, true + } + + distance, ok := activityDistance(detailedTour) + if !ok || distance <= 0 { + app.Logger().Warn(fmt.Sprintf("Skipping Hammerhead activity '%s' with zero distance", tour.Name)) + continue + } + + gpx, err := generateActivityGPX(detailedTour) + if err != nil { + app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err)) + continue + } + + _, err = createTrailFromActivity(app, detailedTour, gpx, actor) + if err != nil { + app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err)) + continue + } + } + + return nil, false +} + +func activityDistance(detailedTour *HammerheadActivity) (float64, bool) { + idDistance := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_DISTANCE_ID" }) + if idDistance < 0 { + return 0, false + } + + return detailedTour.ActivityData.ActivityInfo[idDistance].Value.Value, true +} + +func createTrailFromActivity(app core.App, detailedTour *HammerheadActivity, gpx *filesystem.File, actor string) (string, error) { + trailid := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) + + collection, err := app.FindCollectionByNameOrId("trails") + if err != nil { + return "", err + } + + record := core.NewRecord(collection) + + category, _ := app.FindFirstRecordByData("categories", "name", "Biking" /*ToDo: Mapping*/) + categoryId := "" + if category != nil { + categoryId = category.Id + } + + diffculty := "easy" // ToDo: calculate difficulty + + idDistance := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_DISTANCE_ID" }) + idElevationGain := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_ELEVATION_GAIN_ID" }) + idElevationLoss := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_ELEVATION_LOSS_ID" }) + + duration := 0 + for _, lap := range detailedTour.ActivityData.Laps { + duration += lap.ActiveTime + } + + startLat := float64(0) + startLng := float64(0) + for i, lat := range detailedTour.RecordData.Lat { + if lat != float64(0) { + startLat = lat + startLng = detailedTour.RecordData.Lng[i] + break + } + } + + record.Load(map[string]any{ + "id": trailid, + "name": detailedTour.ActivityData.Name, + "public": false, + "distance": detailedTour.ActivityData.ActivityInfo[idDistance].Value.Value, + "elevation_gain": detailedTour.ActivityData.ActivityInfo[idElevationGain].Value.Value, + "elevation_loss": detailedTour.ActivityData.ActivityInfo[idElevationLoss].Value.Value, + "duration": duration / 1000, + "date": detailedTour.ActivityData.CreatedAt, + "external_provider": "hammerhead", + "external_id": detailedTour.ActivityData.ID, + "lat": startLat, + "lon": startLng, + "difficulty": diffculty, + "category": categoryId, + "author": actor, + }) + + if gpx != nil { + record.Set("gpx", gpx) + } + + if err := app.Save(record); err != nil { + return "", err + } + + collection, err = app.FindCollectionByNameOrId("summit_logs") + if err != nil { + return "", err + } + + summitLogRecord := core.NewRecord(collection) + summitLogRecord.Load(map[string]any{ + "distance": detailedTour.ActivityData.ActivityInfo[idDistance].Value.Value, + "elevation_gain": detailedTour.ActivityData.ActivityInfo[idElevationGain].Value.Value, + "elevation_loss": detailedTour.ActivityData.ActivityInfo[idElevationLoss].Value.Value, + "duration": duration / 1000, + "date": detailedTour.ActivityData.CreatedAt, + "author": actor, + "trail": trailid, + }) + if err := app.Save(summitLogRecord); err != nil { + return "", err + } + + return trailid, nil +} + +func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesystem.File, actor string) (string, error) { + trailid := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) + + collection, err := app.FindCollectionByNameOrId("trails") + if err != nil { + return "", err + } + + record := core.NewRecord(collection) + + category, _ := app.FindFirstRecordByData("categories", "name", "Biking" /*ToDo: Mapping*/) + categoryId := "" + if category != nil { + categoryId = category.Id + } + + diffculty := "easy" // ToDo: calculate difficulty + + record.Load(map[string]any{ + "id": trailid, + "name": detailedTour.Name, + "public": detailedTour.IsPublic, + "distance": detailedTour.Distance, + "elevation_gain": detailedTour.Elevation.Gain, + "elevation_loss": detailedTour.Elevation.Loss, + "date": detailedTour.CreatedAt, + "external_provider": "hammerhead", + "external_id": detailedTour.ID, + "lat": detailedTour.StartLocation.Lat, + "lon": detailedTour.StartLocation.Lng, + "difficulty": diffculty, + "category": categoryId, + "author": actor, + }) + + if gpx != nil { + record.Set("gpx", gpx) + } + + if err := app.Save(record); err != nil { + return "", err + } + + return trailid, nil +} + +func generateActivityGPX(detailedTour *HammerheadActivity) (*filesystem.File, error) { + times := len(detailedTour.RecordData.Timestamp) + if times == 0 { + return nil, nil + } + + var points []gpx.GPXPoint + const zeroEps = 1e-4 + + // iterate over timestamps and only add points when lat/lng exist for the same index + for i := 0; i < times; i++ { + // ensure we have latitude and longitude for this index + if i < len(detailedTour.RecordData.Lat) && i < len(detailedTour.RecordData.Lng) { + lat := detailedTour.RecordData.Lat[i] + lng := detailedTour.RecordData.Lng[i] + + // exclude near (0,0) garbage points + if math.Abs(lat) < zeroEps && math.Abs(lng) < zeroEps { + continue + } + + t := detailedTour.RecordData.Timestamp[i] + + elevation := float64(0) + if i < len(detailedTour.RecordData.Elevation) { + elevation = detailedTour.RecordData.Elevation[i] / 1000.0 + } + + points = append(points, gpx.GPXPoint{ + Point: gpx.Point{ + Latitude: lat, + Longitude: lng, + Elevation: *gpx.NewNullableFloat64(elevation), + }, + Timestamp: time.Unix(int64(t), 0), + }) + } + } + + if len(points) == 0 { + return nil, nil + } + + gpxData := &gpx.GPX{ + Version: "1.1", + Creator: "Hammerhead GPX Exporter", + Tracks: []gpx.GPXTrack{ + { + Name: detailedTour.ActivityData.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.ActivityData.Name+".gpx") + if err != nil { + return nil, err + } + + return gpxFile, nil +} + +func generateTourGPX(detailedTour *HammerheadTour) (*filesystem.File, error) { + + poly := detailedTour.RoutePolyline + coords, err := decodePolyline(poly) + if err != nil { + return nil, fmt.Errorf("decode polyline: %w", err) + } + if len(coords) == 0 { + return nil, nil + } + + // try to get elevation polyline (adjust field path if your struct differs) + elevations := []float64{} + // precision 100 is common for Valhalla elevation encodings; change if needed + if decoded, err := decodeElevations(detailedTour.Elevation.Polyline, 100000); err == nil { + elevations = decoded + } + + // Heuristic: detect if coords are (lng,lat) instead of (lat,lng). + // Count how many points look valid in each orientation and pick the best. + validAsLat := 0 + validAsLng := 0 + for _, c := range coords { + // treat c[0] as lat, c[1] as lng + if c[0] >= -90 && c[0] <= 90 && c[1] >= -180 && c[1] <= 180 { + validAsLat++ + } + // treat c[1] as lat, c[0] as lng (swapped) + if c[1] >= -90 && c[1] <= 90 && c[0] >= -180 && c[0] <= 180 { + validAsLng++ + } + } + swap := false + if validAsLng > validAsLat { + swap = true + } + + var points []gpx.GPXPoint + for i, c := range coords { + lat := c[0] + lng := c[1] + if swap { + lat, lng = c[1], c[0] + } + + // choose elevation: + elevation := 0.0 + if len(elevations) == len(coords) { + elevation = elevations[i] + } else if len(elevations) > 0 { + // map index proportionally if lengths differ + j := int(math.Round(float64(i) * float64(len(elevations)-1) / float64(len(coords)-1))) + if j < 0 { + j = 0 + } + if j >= len(elevations) { + j = len(elevations) - 1 + } + elevation = elevations[j] + } + + points = append(points, gpx.GPXPoint{ + Point: gpx.Point{ + Latitude: lat, + Longitude: lng, + Elevation: *gpx.NewNullableFloat64(elevation), + }, + }) + } + + gpxData := &gpx.GPX{ + Version: "1.1", + Creator: "Hammerhead 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 +} + +// decodePolyline decodes an encoded polyline string (Google Polyline Algorithm) +// returns slice of [lat, lng] pairs (precision 1e5). +func decodePolyline(s string) ([][2]float64, error) { + if s == "" { + return nil, nil + } + var coords [][2]float64 + index := 0 + lat := 0 + lng := 0 + for index < len(s) { + // decode latitude + result := 0 + shift := uint(0) + for { + if index >= len(s) { + return nil, fmt.Errorf("invalid polyline encoding") + } + b := int(s[index]) - 63 + index++ + result |= (b & 0x1F) << shift + shift += 5 + if b < 0x20 { + break + } + } + dlat := (result >> 1) ^ (-(result & 1)) + lat += dlat + + // decode longitude + result = 0 + shift = 0 + for { + if index >= len(s) { + return nil, fmt.Errorf("invalid polyline encoding") + } + b := int(s[index]) - 63 + index++ + result |= (b & 0x1F) << shift + shift += 5 + if b < 0x20 { + break + } + } + dlng := (result >> 1) ^ (-(result & 1)) + lng += dlng + + coords = append(coords, [2]float64{float64(lat) / 1e5, float64(lng) / 1e5}) + } + + // Auto-normalize scale if values are out of realistic lat/lon ranges. + // Some providers use different precision/scales; repeatedly divide by 10 + // until all values fit into valid ranges. + if len(coords) > 0 { + maxLat := 0.0 + maxLng := 0.0 + for _, c := range coords { + if abs := math.Abs(c[0]); abs > maxLat { + maxLat = abs + } + if abs := math.Abs(c[1]); abs > maxLng { + maxLng = abs + } + } + // If values are too large (e.g. > 90 lat or > 180 lon), rescale down. + for (maxLat > 90.0 || maxLng > 180.0) && (maxLat > 0 && maxLng > 0) { + for i := range coords { + coords[i][0] /= 10.0 + coords[i][1] /= 10.0 + } + maxLat /= 10.0 + maxLng /= 10.0 + } + } + + return coords, nil +} + +// decodeElevations decodes a single-dimension delta-encoded polyline string. +// precision is the divisor (e.g. 100 for centi-meters -> meters). Returns elevation values in same units as precision (meters if precision=100). +func decodeElevations(s string, precision float64) ([]float64, error) { + if s == "" { + return nil, nil + } + var elevs []float64 + index := 0 + val := 0 + for index < len(s) { + result := 0 + shift := uint(0) + for { + if index >= len(s) { + return nil, fmt.Errorf("invalid elevation encoding") + } + b := int(s[index]) - 63 + index++ + result |= (b & 0x1F) << shift + shift += 5 + if b < 0x20 { + break + } + } + d := (result >> 1) ^ (-(result & 1)) + val += d + elevs = append(elevs, float64(val)/precision) + } + return elevs, nil +} diff --git a/db/integrations/hammerhead/models.go b/db/integrations/hammerhead/models.go new file mode 100644 index 00000000..77ac83c5 --- /dev/null +++ b/db/integrations/hammerhead/models.go @@ -0,0 +1,206 @@ +package hammerhead + +import ( + "time" +) + +type HammerheadToursResponse struct { + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + PerPage int `json:"perPage"` + CurrentPage int `json:"currentPage"` + Data []HammerheadTourResponse `json:"data"` +} +type HammerheadTourResponse struct { + StartLocationName string `json:"startLocationName"` + IsAutoImported bool `json:"isAutoImported"` + SummaryPolyline string `json:"summaryPolyline"` + IsStarred bool `json:"isStarred"` + IsPublic bool `json:"isPublic"` + Collections any `json:"collections"` + Gain int `json:"gain"` + Distance float64 `json:"distance"` + Name string `json:"name"` + RoutingType string `json:"routingType"` + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Source string `json:"source"` +} + +type HammerheadTourElevation struct { + Gain float64 `json:"gain"` + Loss float64 `json:"loss"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Source string `json:"source"` + Polyline string `json:"polyline"` +} +type HammerheadLocation struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} +type HammerheadWaypoint struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + WaypointType string `json:"waypointType"` + PolylineIndex int `json:"polylineIndex"` +} + +type HammerheadTour struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Name string `json:"name"` + Distance float64 `json:"distance"` + Elevation HammerheadTourElevation `json:"elevation"` + IsStarred bool `json:"isStarred"` + StartLocationName string `json:"startLocationName"` + EndLocationName string `json:"endLocationName"` + StartLocation HammerheadLocation `json:"startLocation"` + EndLocation HammerheadLocation `json:"endLocation"` + Waypoints []HammerheadWaypoint `json:"waypoints"` + Collections []string `json:"collections"` + RoutePolyline string `json:"routePolyline"` + SummaryPolyline string `json:"summaryPolyline"` + Source string `json:"source"` + SourceID string `json:"sourceId"` + IsPublic bool `json:"isPublic"` + ImageVersion string `json:"imageVersion"` + IsAutoImported bool `json:"isAutoImported"` + UpdatedAt time.Time `json:"updatedAt"` + Bounds []HammerheadLocation `json:"bounds"` +} + +type HammerheadIntegration struct { + Active bool `json:"active"` + Email string `json:"email"` + Password string `json:"password"` + Planned bool `json:"planned"` + Completed bool `json:"completed"` + After string `json:"after,omitempty"` +} + +type LoginResponse struct { + Token string `json:"access_token"` + Type string `json:"token_type"` + Expires int `json:"expires_in"` +} + +type HammerheadActivitiesResponse struct { + Items int `json:"totalItems"` + Pages int `json:"totalPages"` + PerPage int `json:"perPage"` + Tours []HammerheadActivityResponse `json:"data"` +} + +type HammerheadActivityResponse struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Name string `json:"name"` + Client string `json:"client"` + ActiveTime int `json:"activeTime"` + Duration HammerheadTourDuration `json:"duration"` + Sync HammerheadSync `json:"partners"` + ActivityInfo []HammerheadInfo `json:"activityInfo"` +} +type HammerheadInfoValue struct { + Format string `json:"format"` + Value float64 `json:"value"` +} +type HammerheadInfo struct { + Key string `json:"key"` + Value HammerheadInfoValue `json:"value"` +} +type HammerheadPartner struct { + Partner string `json:"partner"` + NeedsUpload bool `json:"needsUpload"` + ExternalID string `json:"externalId"` + Attempts int `json:"attempts"` + UploadedAt time.Time `json:"uploadedAt"` +} +type HammerheadSync struct { + Description string `json:"description"` + Tags []any `json:"tags"` + Synced bool `json:"synced"` + Partners []HammerheadPartner `json:"partners"` +} +type HammerheadTourDuration struct { + ElapsedTime int `json:"elapsedTime"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` +} + +type HammerheadActivity struct { + ActivityData HammerheadActivityData `json:"activityData"` + SessionData HammerheadSessionData `json:"sessionData"` + RecordData HammerheadRecordData `json:"recordData"` + ShiftData HammerheadShiftData `json:"shiftData"` + LapData HammerheadLapData `json:"lapData"` + DeviceBatteryData HammerheadDeviceBatteryData `json:"deviceBatteryData"` +} +type HammerheadDuration struct { + ElapsedTime int `json:"elapsedTime"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` +} +type HammerheadLapDetail struct { + ActiveTime int `json:"activeTime"` + Duration HammerheadDuration `json:"duration"` + LapNumber int `json:"lapNumber"` + Pauses []HammerheadDuration `json:"pauses"` + LapInfo []HammerheadInfo `json:"lapInfo"` + Trigger string `json:"trigger"` +} +type HammerheadActivityData struct { + ID string `json:"id"` + Name string `json:"name"` + BikeID string `json:"bikeId"` + Client string `json:"client"` + ActiveTime int `json:"activeTime"` + Duration HammerheadDuration `json:"duration"` + ActivityInfo []HammerheadInfo `json:"activityInfo"` + Laps []HammerheadLapDetail `json:"laps"` + Polyline string `json:"polyline"` + Sync HammerheadSync `json:"sync"` + ActivityType string `json:"activityType"` + Climbs []HammerheadClimb `json:"climbs"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} +type HammerheadClimb struct { + StartDistance float64 `json:"startDistance"` + EndDistance float64 `json:"endDistance"` + Distance float64 `json:"distance"` +} +type HammerheadSessionData struct { + ThresholdPower int `json:"thresholdPower"` + FrontGears []int `json:"frontGears"` + RearGears []int `json:"rearGears"` +} +type HammerheadRecordData struct { + Distance []float64 `json:"distance"` + Timestamp []int `json:"timestamp"` + Elevation []float64 `json:"elevation"` + Grade []float64 `json:"grade"` + Lat []float64 `json:"lat"` + Lng []float64 `json:"lng"` + Speed []float64 `json:"speed"` + Power []any `json:"power"` + Temperature []int `json:"temperature"` +} +type HammerheadShiftData struct { + Timestamp []int `json:"timestamp"` + FrontChange []bool `json:"frontChange"` + FrontGear []int `json:"frontGear"` + RearGear []int `json:"rearGear"` + FrontGearNum []int `json:"frontGearNum"` + RearGearNum []int `json:"rearGearNum"` +} +type HammerheadLapData struct { + Timestamp []int `json:"timestamp"` + Trigger []string `json:"trigger"` +} +type HammerheadDeviceBatteryData struct { + Timestamp []int `json:"timestamp"` + DeviceBattery []int `json:"deviceBattery"` +} diff --git a/db/main.go b/db/main.go index e5117d90..267e6df0 100644 --- a/db/main.go +++ b/db/main.go @@ -25,6 +25,7 @@ import ( "pocketbase/commands" "pocketbase/federation" + "pocketbase/integrations/hammerhead" "pocketbase/integrations/komoot" "pocketbase/integrations/strava" @@ -858,8 +859,9 @@ func updateIntegrationHandler() func(e *core.RecordEvent) error { } func censorIntegrationSecrets(r *core.Record) error { secrets := map[string][]string{ - "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, - "komoot": {"password"}, + "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, + "komoot": {"password"}, + "hammerhead": {"password"}, } for key, secretKeys := range secrets { if integrationString := r.GetString(key); integrationString != "" { @@ -891,8 +893,9 @@ func encryptIntegrationSecrets(app core.App, r *core.Record) error { } secrets := map[string][]string{ - "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, - "komoot": {"password"}, + "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, + "komoot": {"password"}, + "hammerhead": {"password"}, } original, _ := app.FindRecordById("integrations", r.Id) @@ -1214,6 +1217,28 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { return e.JSON(http.StatusOK, nil) }) + se.Router.POST("/integration/hammerhead/upload", func(e *core.RequestEvent) error { + h, err := loginHammerhead(e) + if err != nil { + return err + } + + if err := h.UploadActivities(e); err != nil { + return err + } + + return e.JSON(http.StatusOK, nil) + }) + + se.Router.GET("/integration/hammerhead/login", func(e *core.RequestEvent) error { + _, err := loginHammerhead(e) + if err != nil { + return err + } + + return e.JSON(http.StatusOK, nil) + }) + se.Router.GET("/integration/komoot/login", func(e *core.RequestEvent) error { encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") if len(encryptionKey) == 0 { @@ -1389,9 +1414,59 @@ func registerCronJobs(app core.App) { fmt.Println(warning) app.Logger().Error(warning) } + err = hammerhead.SyncHammerhead(app) + if err != nil { + warning := fmt.Sprintf("Error syncing with hammerhead: %v", err) + fmt.Println(warning) + app.Logger().Error(warning) + } }) } +func loginHammerhead(e *core.RequestEvent) (*hammerhead.HammerheadApi, error) { + + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return nil, apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil) + } + + userId := "" + if e.Auth != nil { + userId = e.Auth.Id + } + + integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId})) + if err != nil { + return nil, err + } + if len(integrations) == 0 { + return nil, apis.NewBadRequestError("user has no integration", nil) + } + integration := integrations[0] + hammerheadString := integration.GetString("hammerhead") + if len(hammerheadString) == 0 { + return nil, apis.NewBadRequestError("hammerhead integration missing", nil) + } + var hammerheadIntegration hammerhead.HammerheadIntegration + err = json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration) + if err != nil { + return nil, err + } + decryptedPassword, err := security.Decrypt(hammerheadIntegration.Password, encryptionKey) + if err != nil { + return nil, err + } + + k := &hammerhead.HammerheadApi{} + + err = k.Login(hammerheadIntegration.Email, string(decryptedPassword)) + if err != nil { + return nil, apis.NewUnauthorizedError("invalid credentials", nil) + } + + return k, e.JSON(http.StatusOK, nil) +} + func bootstrapData(app core.App, client meilisearch.ServiceManager) error { bootstrapCategories(app) bootstrapMeilisearchConfig(client) diff --git a/db/migrations/1760706161_updated_integrations.go b/db/migrations/1760706161_updated_integrations.go new file mode 100644 index 00000000..f94dc80a --- /dev/null +++ b/db/migrations/1760706161_updated_integrations.go @@ -0,0 +1,41 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("iz4sezoehde64wp") + if err != nil { + return err + } + + // add field + if err := collection.Fields.AddMarshaledJSONAt(4, []byte(`{ + "hidden": false, + "id": "json2528191900", + "maxSize": 2000000, + "name": "hammerhead", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }`)); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("iz4sezoehde64wp") + if err != nil { + return err + } + + // remove field + collection.Fields.RemoveById("json2528191900") + + return app.Save(collection) + }) +} diff --git a/db/migrations/1760715417_updated_trails.go b/db/migrations/1760715417_updated_trails.go new file mode 100644 index 00000000..d0e34c0c --- /dev/null +++ b/db/migrations/1760715417_updated_trails.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + // update field + if err := collection.Fields.AddMarshaledJSONAt(20, []byte(`{ + "hidden": false, + "id": "htr35nha", + "maxSelect": 1, + "name": "external_provider", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "strava", + "komoot", + "hammerhead" + ] + }`)); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + // update field + if err := collection.Fields.AddMarshaledJSONAt(20, []byte(`{ + "hidden": false, + "id": "htr35nha", + "maxSelect": 1, + "name": "external_provider", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "strava", + "komoot" + ] + }`)); err != nil { + return err + } + + return app.Save(collection) + }) +} diff --git a/docs/src/content/docs/use/integrations.md b/docs/src/content/docs/use/integrations.md index a19528a6..d5a0e894 100644 --- a/docs/src/content/docs/use/integrations.md +++ b/docs/src/content/docs/use/integrations.md @@ -3,31 +3,31 @@ title: Integrations description: How to set up third-party integrations with wanderer. --- -You can automatically sync trails to wanderer at regular intervals using the third-party integration feature. Currently, we support two providers: **strava** and **komoot**. +You can automatically sync trails to wanderer at regular intervals using the third-party integration feature. Currently, we support three providers: **Strava**, **komoot** and **hammerhead**. -It is important to note that synchronization only works from the provider to wanderer and not the other way around. Additionally, if a trail has already been synced to wanderer, subsequent changes made in the provider will not be transferred unless the trail is deleted in wanderer. +It is important to note that synchronization only works from the provider to wanderer and not the other way around. Additionally, if a trail has already been synced to wanderer, subsequent changes made in the provider will not be transferred unless the trail is deleted in wanderer. Hammerhead also supports manual uploads from a trail's action menu, which is separate from the nightly sync. -## strava Integration +## Strava Integration -### Creating an App in strava +### Creating an App in Strava -Before integrating strava with wanderer, you need to create an API application in strava. Visit [strava's API settings](https://www.strava.com/settings/api) and follow the steps to create a new API application. Your setup should resemble the following: +Before integrating Strava with wanderer, you need to create an API application in Strava. Visit [Strava's API settings](https://www.strava.com/settings/api) and follow the steps to create a new API application. Your setup should resemble the following: -![strava API Application](../../../assets/guides/strava_api_app.png) +![Strava API Application](../../../assets/guides/strava_api_app.png) ### Setting Up the Integration 1. Copy the **Client ID** and **Client Secret**. 2. Go to the integrations page in wanderer's settings. -3. Click the settings button for the strava integration. +3. Click the settings button for the Strava integration. 4. Enter your **Client ID** and **Client Secret**. 5. Choose whether you want to sync routes, activities, or both. -![wanderer strava Integration](../../../assets/guides/wanderer_integration_strava.png) +![wanderer Strava Integration](../../../assets/guides/wanderer_integration_strava.png) 6. Save the settings and toggle the integration on. -7. You will be redirected to strava's authorization page. Keep all checkboxes selected and click **Authorize**. -8. You will then be redirected back to wanderer. The strava integration is now active. +7. You will be redirected to Strava's authorization page. Keep all checkboxes selected and click **Authorize**. +8. You will then be redirected back to wanderer. The Strava integration is now active. ## komoot Integration @@ -40,11 +40,20 @@ The komoot integration requires only your komoot username and password: Your planned and completed trails will now sync with wanderer. +## Hammerhead Integration + +The Hammerhead integration requires your Hammerhead account details: + +1. Open the Hammerhead settings from the integrations menu. +2. Enter your Hammerhead email and password. +3. Choose whether you want to sync planned tours, completed tours, or both. +4. (Optional) Set an "ignore trails before" date to avoid syncing duplicates if your Hammerhead account is already connected to other services. +5. Save the settings and toggle the integration on. It will become active immediately after a successful login. + ## Sync Interval By default, trails are synced every night at **02:00 AM**. You can modify this schedule using the `POCKETBASE_CRON_SYNC_SCHEDULE` [environment variable](/run/environment-configuration#pocketbase). :::note -Please set a reasonable sync interval. Both strava and komoot impose usage limits on their APIs. Exceeding these limits may result in rejected requests or account suspension. +Please set a reasonable sync interval. Both Strava and komoot impose usage limits on their APIs. Exceeding these limits may result in rejected requests or account suspension. ::: - diff --git a/web/src/lib/assets/svgs/logos/hammerhead_dark.svg b/web/src/lib/assets/svgs/logos/hammerhead_dark.svg new file mode 100644 index 00000000..3f00afb0 --- /dev/null +++ b/web/src/lib/assets/svgs/logos/hammerhead_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/web/src/lib/assets/svgs/logos/hammerhead_white.svg b/web/src/lib/assets/svgs/logos/hammerhead_white.svg new file mode 100644 index 00000000..59d98b0c --- /dev/null +++ b/web/src/lib/assets/svgs/logos/hammerhead_white.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte b/web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte new file mode 100644 index 00000000..5f8ff1d1 --- /dev/null +++ b/web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte @@ -0,0 +1,122 @@ + + + + {#snippet content()} +
+ + +
+ + +
+

+ {$_("hammerhead-integration-after-date-hint")} +

+
+ + +
+
+ {/snippet} + {#snippet footer()} +
+ + +
+ {/snippet}
diff --git a/web/src/lib/components/settings/integrations/strava_settings_modal.svelte b/web/src/lib/components/settings/integrations/strava_settings_modal.svelte index a5afec83..270c14f7 100644 --- a/web/src/lib/components/settings/integrations/strava_settings_modal.svelte +++ b/web/src/lib/components/settings/integrations/strava_settings_modal.svelte @@ -70,7 +70,7 @@ {#snippet content()} @@ -114,7 +114,7 @@
+
+ {/snippet} + {#snippet footer()} +
+ +
+ {/snippet}
diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index e7e6152a..095652a9 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -149,6 +149,7 @@ "error-exporting-trail": "Fehler beim Exportieren der Route", "error-generating-token": "", "error-liking-trail": "Error liking trail", + "error-logging-in-to-hammerhead": "Fehler bei der Anmeldung bei Hammerhead", "error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot", "error-posting-comment": "Fehler beim Posten des Kommentars", "error-printing-map": "Fehler beim Drucken der Karte", @@ -157,7 +158,10 @@ "error-saving-trail": "Fehler beim Speichern der Route", "error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration", "error-updating-password": "Fehler beim Aktualisieren des Passworts", - "error-updating-strava-integration": "Fehler bei Aktualisierung der komoot-Integration", + "error-updating-hammerhead-integration": "Fehler bei Aktualisierung der Hammerhead-Integration", + "error-updating-komoot-integration": "Fehler bei Aktualisierung der komoot-Integration", + "error-updating-strava-integration": "Fehler bei Aktualisierung der Strava-Integration", + "error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead", "est-duration": "Gesch. Dauer", "everyone-with-the-link": "Jeder mit dem Link", "expiration": "", @@ -196,6 +200,7 @@ "get-started": "Los geht’s", "grid": "Gitter", "grocery-store": "Lebensmittelgeschäft", + "hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", "heading": "Überschrift", "height": "Höhe", "help": "Hilfe", @@ -211,11 +216,13 @@ "hut": "Hütte", "hybrid": "Hybrid", "icon": "Icon", + "ignore-trails-before-date": "Routen vor diesem Datum ignorieren", "imperial": "Imperial", "import": "Importieren", "import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...", "include-description": "Beschreibung übernehmen", "include-waypoints": "Wegpunkte einbeziehen", + "integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.", "integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.", "integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.", "integration-disabled": "Integration deaktiviert", @@ -372,6 +379,7 @@ "search-trails": "Route suchen", "select-list": "Liste auswählen", "selected": "ausgewählt", + "send-to": "Senden an...", "set-private": "Verbergen", "set-public": "Veröffentlichen", "settings": "Einstellungen", @@ -417,7 +425,7 @@ "statistics": "Statistiken", "stop-drawing": "Zeichnen beenden", "stop-editing": "Bearbeiten beenden", - "strava-integration-after-date-hint": "Wenn in deinem Konto sehr viele Aktivitäten gespeichert sind, kann es aufgrund von API-Abfragelimits bei Strava vorkommen, dass nicht alle Aktivitäten auf einmal synchronisiert werden können. Um dieses Problem zu begrenzen, kannst du unten ein „Danach“-Datum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", + "strava-integration-after-date-hint": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", "subway-stop": "U-Bahn Eingang", "summit": "Gipfel", "summit-book": "Gipfelbuch", @@ -429,6 +437,7 @@ "top-speed": "Höchstgeschwindigkeit", "tourism": "Tourismus", "trail": "{n, plural, =1 {Route} other {Routen}}", + "trail-has-no-gpx": "Diese Route besitzt keine GPX Daten.", "trail-copied-successfully": "Route erfolgreich kopiert", "trail-not-in-list": "Trail gehört zu keiner Liste.", "trail-not-shared": "Mit niemandem geteilt", @@ -442,6 +451,7 @@ "upload-gpx": "GPX hochladen", "upload-new-file": "Neue Datei hochladen", "uploaded": "hochgeladen", + "uploaded-trail-to-hammerhead": "Route erfolgreich zu Hammerhead hochgeladen", "use-hills": "Hügel einbeziehen", "use-roads": "Nutze Straßen", "username": "Nutzername", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 3230c2c9..92aeaeb1 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -149,6 +149,7 @@ "error-exporting-trail": "Error exporting trail", "error-generating-token": "Error generating token", "error-liking-trail": "Error liking trail", + "error-logging-in-to-hammerhead": "Error logging in to Hammerhead", "error-logging-in-to-komoot": "Error logging in to komoot", "error-posting-comment": "Error posting comment", "error-printing-map": "Error printing map", @@ -157,7 +158,10 @@ "error-saving-trail": "Error saving trail", "error-setting-up-integration": "Error setting up {provider} integration", "error-updating-password": "Error updating password", - "error-updating-strava-integration": "Error updating komoot integration", + "error-updating-hammerhead-integration": "Error updating Hammerhead integration", + "error-updating-komoot-integration": "Error updating komoot integration", + "error-updating-strava-integration": "Error updating Strava integration", + "error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead", "est-duration": "Est. duration", "everyone-with-the-link": "Everyone with the link", "expiration": "Expiration", @@ -196,6 +200,7 @@ "get-started": "Get started", "grid": "Grid", "grocery-store": "Grocery store", + "hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.", "heading": "Heading", "height": "Height", "help": "Help", @@ -211,13 +216,15 @@ "hut": "Hut", "hybrid": "Hybrid", "icon": "Icon", + "ignore-trails-before-date": "Ignore trails before this date", "imperial": "Imperial", "import": "Import", "import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "include-description": "Include description", "include-waypoints": "Include waypoints", + "integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.", "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", + "integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.", "integration-disabled": "integration disabled", "integration-enabled": "integration enabled", "integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.", @@ -372,6 +379,7 @@ "search-trails": "Search trails", "select-list": "Select List", "selected": "selected", + "send-to": "Send to...", "set-private": "Set private", "set-public": "Set public", "settings": "Settings", @@ -417,7 +425,7 @@ "statistics": "Statistics", "stop-drawing": "Stop drawing", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", + "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Summit Book", @@ -429,6 +437,7 @@ "top-speed": "Top Speed", "tourism": "Tourism", "trail": "{n, plural, =1 {Trail} other {Trails}}", + "trail-has-no-gpx": "This trail has no GPX data.", "trail-copied-successfully": "trail copied successfully", "trail-not-in-list": "Trail is not in any list", "trail-not-shared": "Not shared with anyone", @@ -442,6 +451,7 @@ "upload-gpx": "Upload GPX", "upload-new-file": "Upload new file", "uploaded": "uploaded", + "uploaded-trail-to-hammerhead": "Successfully uploaded trail to Hammerhead", "use-hills": "Use hills", "use-roads": "Use Roads", "username": "Username", diff --git a/web/src/lib/models/api/integration_schema.ts b/web/src/lib/models/api/integration_schema.ts index 694ed755..b5ef283f 100644 --- a/web/src/lib/models/api/integration_schema.ts +++ b/web/src/lib/models/api/integration_schema.ts @@ -20,16 +20,27 @@ const KomootSchema = z.object({ privacy: z.enum(["original", "settings"]) }) +const HammerheadSchema = z.object({ + email: z.string().email(), + password: z.string(), + completed: z.boolean(), + planned: z.boolean(), + active: z.boolean(), + after: z.string().date().optional(), +}) + const IntegrationCreateSchema = z.object({ user: z.string().length(15), strava: StravaSchema.optional(), - komoot: KomootSchema.optional() + komoot: KomootSchema.optional(), + hammerhead: HammerheadSchema.optional(), }) satisfies ZodType const IntegrationUpdateSchema = z.object({ strava: StravaSchema.optional().nullable(), - komoot: KomootSchema.optional().nullable() + komoot: KomootSchema.optional().nullable(), + hammerhead: HammerheadSchema.optional().nullable(), }) satisfies ZodType> -export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema }; +export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema }; diff --git a/web/src/lib/models/integration.ts b/web/src/lib/models/integration.ts index b0f85576..509bca30 100644 --- a/web/src/lib/models/integration.ts +++ b/web/src/lib/models/integration.ts @@ -23,16 +23,26 @@ export interface KomootIntegration extends BaseIntegration { privacy: "original" | "settings" } +export interface HammerheadIntegration extends BaseIntegration { + email: string, + password: string, + completed: boolean, + planned: boolean, + after?: string +} + export class Integration { id?: string; user: string; strava?: StravaIntegration | null; - komoot?: KomootIntegration | null + komoot?: KomootIntegration | null; + hammerhead?: HammerheadIntegration | null; - constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration) { + constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration, hammerhead?: HammerheadIntegration) { this.user = user; this.strava = strava; this.komoot = komoot; + this.hammerhead = hammerhead; } } \ No newline at end of file diff --git a/web/src/lib/stores/integration_store.ts b/web/src/lib/stores/integration_store.ts index 1297a06c..12f188db 100644 --- a/web/src/lib/stores/integration_store.ts +++ b/web/src/lib/stores/integration_store.ts @@ -47,6 +47,21 @@ export async function integrations_create(integration: Integration) { return model; } +export async function uploadGpx(integrationName: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + let r = await fetch(`/api/v1/integration/${integrationName}/upload`, { + method: 'POST', + body: formData, + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } +} + export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { let r = await f('/api/v1/integration/' + integration.id, { method: 'POST', diff --git a/web/src/routes/api/v1/integration/hammerhead/login/+server.ts b/web/src/routes/api/v1/integration/hammerhead/login/+server.ts new file mode 100644 index 00000000..2ba35501 --- /dev/null +++ b/web/src/routes/api/v1/integration/hammerhead/login/+server.ts @@ -0,0 +1,13 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +export async function GET(event: RequestEvent) { + try { + const r = await event.locals.pb.send("/integration/hammerhead/login", { + method: "GET", + }); + return json(r); + } catch (e: any) { + return handleError(e) + } +} \ No newline at end of file diff --git a/web/src/routes/api/v1/integration/hammerhead/upload/+server.ts b/web/src/routes/api/v1/integration/hammerhead/upload/+server.ts new file mode 100644 index 00000000..e7fa819e --- /dev/null +++ b/web/src/routes/api/v1/integration/hammerhead/upload/+server.ts @@ -0,0 +1,22 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +export async function POST(event: RequestEvent) { + try { + const formData = await event.request.formData(); + const file = formData.get("file"); + + if (!(file instanceof Blob)) { + return json({ message: "missing_file" }, { status: 400 }); + } + + const r = await event.locals.pb.send("/integration/hammerhead/upload", { + method: "POST", + body: formData, + fetch: event.fetch, + }); + return json(r); + } catch (e: any) { + return handleError(e) + } +} diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte index 6a7bb830..55f5943d 100644 --- a/web/src/routes/settings/integrations/+page.svelte +++ b/web/src/routes/settings/integrations/+page.svelte @@ -1,12 +1,14 @@ @@ -167,7 +231,7 @@
komootSettingsModal.openModal()} ontoggle={onKomootToggle} > + hammerheadSettingsModal.openModal()} + ontoggle={onHammerheadToggle} + >
onSettingsSave(form, "komoot")} > + + onSettingsSave(form, "hammerhead")} +>