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 <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-03-20 13:58:34 +01:00
committed by GitHub
parent 5a05573e83
commit 369e71fcec
20 changed files with 1839 additions and 51 deletions

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -25,6 +25,7 @@ import (
"pocketbase/commands"
"pocketbase/federation"
"pocketbase/integrations/hammerhead"
"pocketbase/integrations/komoot"
"pocketbase/integrations/strava"
@@ -860,6 +861,7 @@ func censorIntegrationSecrets(r *core.Record) error {
secrets := map[string][]string{
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
"komoot": {"password"},
"hammerhead": {"password"},
}
for key, secretKeys := range secrets {
if integrationString := r.GetString(key); integrationString != "" {
@@ -893,6 +895,7 @@ func encryptIntegrationSecrets(app core.App, r *core.Record) error {
secrets := map[string][]string{
"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)

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -3,31 +3,31 @@ title: Integrations
description: How to set up third-party integrations with wanderer.
---
You can automatically sync trails to <span class="-tracking-[0.075em]">wanderer</span> at regular intervals using the third-party integration feature. Currently, we support two providers: **strava** and **komoot**.
You can automatically sync trails to <span class="-tracking-[0.075em]">wanderer</span> 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 <span class="-tracking-[0.075em]">wanderer</span> and not the other way around. Additionally, if a trail has already been synced to <span class="-tracking-[0.075em]">wanderer</span>, subsequent changes made in the provider will not be transferred unless the trail is deleted in <span class="-tracking-[0.075em]">wanderer</span>.
It is important to note that synchronization only works from the provider to <span class="-tracking-[0.075em]">wanderer</span> and not the other way around. Additionally, if a trail has already been synced to <span class="-tracking-[0.075em]">wanderer</span>, subsequent changes made in the provider will not be transferred unless the trail is deleted in <span class="-tracking-[0.075em]">wanderer</span>. 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 <span class="-tracking-[0.075em]">wanderer</span>, 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 <span class="-tracking-[0.075em]">wanderer</span>, 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 <span class="-tracking-[0.075em]">wanderer</span>'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 <span class="-tracking-[0.075em]">wanderer</span>. 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 <span class="-tracking-[0.075em]">wanderer</span>. 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 <span class="-tracking-[0.075em]">wanderer</span>.
## 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.
:::

View File

@@ -0,0 +1,15 @@
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
</g>
<path
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
transform="translate(0 -0.2)" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,15 @@
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
</g>
<path fill="white"
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
transform="translate(0 -0.2)" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import Datepicker from "$lib/components/base/datepicker.svelte";
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import { HammerheadSchema } from "$lib/models/api/integration_schema";
import type {
Integration,
HammerheadIntegration,
} from "$lib/models/integration";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
interface Props {
integration?: Integration;
onsave?: (hammerheadIntegration: HammerheadIntegration) => void;
}
let { integration, onsave }: Props = $props();
let modal: Modal;
export function openModal() {
errors.set({});
modal.openModal();
}
const getInitialFormValues = () => ({
email: integration?.hammerhead?.email ?? "",
password: integration?.hammerhead?.password ?? "",
completed: integration?.hammerhead?.completed ?? true,
planned: integration?.hammerhead?.planned ?? true,
active: integration?.hammerhead?.active ?? false,
after: integration?.hammerhead?.after,
});
const {
form,
errors,
data: formData,
} = createForm({
initialValues: getInitialFormValues(),
extend: validator({
schema: HammerheadSchema,
}),
onSubmit: async (form) => {
form.active = integration?.hammerhead?.active ?? form.active;
onsave?.(form);
modal.closeModal();
},
});
function clearAfterDate() {
($formData as any).after = undefined;
}
</script>
<Modal
id="hammerhead-settings-modal"
size="md:max-w-lg"
title={"Hammerhead " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form id="hammerhead-settings-form" class="space-y-2" use:form>
<TextField
label={$_("email")}
placeholder="user@example.com"
name="email"
error={$errors.email}
></TextField>
<TextField
label={$_("password")}
placeholder={integration?.hammerhead ? `(${$_("unchanged")})` : ""}
name="password"
type="password"
error={$errors.password}
></TextField>
<div class="flex flex-wrap gap-x-4">
<Toggle name="planned" label={$_("planned-tours", { values: { n: 2 } })}
></Toggle>
<Toggle
name="completed"
label={$_("completed-tours", { values: { n: 2 } })}
></Toggle>
</div>
<p
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
>
{$_("hammerhead-integration-after-date-hint")}
</p>
<div class="flex items-end relative gap-x-2">
<Datepicker
error={$errors.after}
label={$_("ignore-trails-before-date")}
bind:value={$formData.after}
></Datepicker>
<button
class="btn-icon mb-[10px]"
type="button"
onclick={clearAfterDate}
aria-label="Clear 'after' date"
><i class="fa fa-close"></i></button
>
</div>
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="hammerhead-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -70,7 +70,7 @@
<Modal
id="strava-settings-modal"
size="md:min-w-lg"
title={"strava " + $_("settings")}
title={"Strava " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
@@ -114,7 +114,7 @@
<div class="flex items-end relative gap-x-2 pt-2 border-t border-input-border">
<Datepicker
error={$errors.after}
label={$_("after")}
label={$_("ignore-trails-before-date")}
bind:value={$formData.after}
></Datepicker>
<button

View File

@@ -3,7 +3,11 @@
import { page } from "$app/state";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import { categories } from "$lib/stores/category_store.js";
import {
integrations,
integrations_index,
uploadGpx,
} from "$lib/stores/integration_store";
import {
lists_add_trail,
lists_index,
@@ -17,12 +21,14 @@
import { trail2gpx } from "$lib/util/gpx_util";
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import JSZip from "jszip";
import type { Snippet } from "svelte";
import { onMount, type Snippet } from "svelte";
import { _ } from "svelte-i18n";
import { get } from "svelte/store";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import ConfirmModal from "../confirm_modal.svelte";
import ListSearchModal from "../list/list_search_modal.svelte";
import TrailExportModal from "./trail_export_modal.svelte";
import TrailSendModal from "./trail_send_modal.svelte";
import TrailShareModal from "./trail_share_modal.svelte";
interface Props {
@@ -40,8 +46,56 @@
let listSelectModal: ListSearchModal;
let trailExportModal: TrailExportModal;
let trailShareModal: TrailShareModal;
let trailSendModal: TrailSendModal;
const hammerheadIntegration = $derived(
$integrations.find((integration) =>
Boolean(integration.hammerhead?.active)
)
);
let lists: List[] = $state([]);
let integrationsLoading = false;
let integrationsLoadedForUser: string | undefined;
onMount(() => {
const unsubscribe = currentUser.subscribe(async (user) => {
if (!user) {
integrationsLoadedForUser = undefined;
return;
}
const existing = get(integrations);
if (
existing.length &&
existing[0]?.user === user.id
) {
integrationsLoadedForUser = user.id;
return;
}
if (
integrationsLoading ||
integrationsLoadedForUser === user.id
) {
return;
}
integrationsLoading = true;
try {
await integrations_index();
integrationsLoadedForUser = user.id;
} catch (error) {
console.error("Failed to load integrations", error);
} finally {
integrationsLoading = false;
}
});
return () => {
unsubscribe();
};
});
let loading: boolean = $state(false);
@@ -212,6 +266,15 @@
},
]
: []),
...(!isMultiselectMode() && hammerheadIntegration && canExport()
? [
{
text: $_("send-to"),
value: "send-to",
icon: "upload",
},
]
: []),
];
}
@@ -336,6 +399,51 @@
updateTrailsVisibility();
} else if (ddVal == "delete") {
confirmModal.openModal();
} else if (item.value == "send-to") {
trailSendModal.openModal();
}
}
async function uploadToHammerhead() {
if (!hammerheadIntegration || !hasTrail()) {
console.error("No Hammerhead integration found.");
return;
}
for (const uTrail of trails!) {
try {
if (uTrail.gpx) {
const gpxData = await trail2gpx(uTrail, $currentUser);
const formData = new FormData();
const gpxFile = new File(
[gpxData],
`${uTrail.name || "trail"}.gpx`,
{ type: "application/gpx+xml" },
);
formData.append("file", gpxFile);
await uploadGpx("hammerhead", gpxFile);
show_toast({
type: "success",
icon: "check",
text: $_("uploaded-trail-to-hammerhead"),
});
} else {
show_toast({
type: "error",
icon: "close",
text: $_("trail-has-no-gpx"),
});
}
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: $_("error-uploading-trail-to-hammerhead"),
});
}
}
}
@@ -617,3 +725,11 @@
onsave={handleShareUpdate}
bind:this={trailShareModal}
></TrailShareModal>
<TrailSendModal
bind:this={trailSendModal}
onsend={async (settings) => {
if (settings.integrationName === "hammerhead") {
await uploadToHammerhead();
}
}}
></TrailSendModal>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { _ } from "svelte-i18n";
import hammerheadLogoWhite from "$lib/assets/svgs/logos/hammerhead_white.svg";
import hammerheadLogoDark from "$lib/assets/svgs/logos/hammerhead_dark.svg";
import { theme } from "$lib/stores/theme_store";
interface Props {
title?: string;
onsend?: (settings: typeof sendSettings) => void;
}
let { title = $_("send-to"), onsend: onexport }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
const sendSettings: {
integrationName: string;
} = $state({
integrationName: "hammerhead",
});
function sendTrail() {
onexport?.(sendSettings);
modal.closeModal();
}
</script>
<Modal id="send-modal" {title} size="md:min-w-sm" bind:this={modal}>
{#snippet content()}
<div>
<button class="btn-secondary" onclick={sendTrail}>
<img class="h-20" src={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite} alt="integration logo"/>
</button>
</div>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -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 gehts",
"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",

View File

@@ -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",

View File

@@ -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<Integration>
const IntegrationUpdateSchema = z.object({
strava: StravaSchema.optional().nullable(),
komoot: KomootSchema.optional().nullable()
komoot: KomootSchema.optional().nullable(),
hammerhead: HammerheadSchema.optional().nullable(),
}) satisfies ZodType<Partial<Integration>>
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema };
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema };

View File

@@ -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;
}
}

View File

@@ -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<Response> = fetch) {
let r = await f('/api/v1/integration/' + integration.id, {
method: 'POST',

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import { page } from "$app/state";
import HammerheadSettingsModal from "$lib/components/settings/integrations/hammerhead_settings_modal.svelte";
import IntegrationCard from "$lib/components/settings/integrations/integration_card.svelte";
import KomootSettingsModal from "$lib/components/settings/integrations/komoot_settings_modal.svelte";
import StravaSettingsModal from "$lib/components/settings/integrations/strava_settings_modal.svelte";
import {
Integration,
type HammerheadIntegration,
type KomootIntegration,
type StravaIntegration
type StravaIntegration,
} from "$lib/models/integration.js";
import {
integrations_create,
@@ -15,6 +17,9 @@
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import { untrack } from "svelte";
import { _ } from "svelte-i18n";
import hammerheadLogoWhite from "$lib/assets/svgs/logos/hammerhead_white.svg";
import hammerheadLogoDark from "$lib/assets/svgs/logos/hammerhead_dark.svg";
import { theme } from "$lib/stores/theme_store";
let { data } = $props();
@@ -33,9 +38,14 @@
untrack(() => data.integration?.komoot?.active ?? false),
);
let hammerheadSettingsModal: HammerheadSettingsModal;
let hammerheadToggleValue: boolean = $state(
untrack(() => data.integration?.hammerhead?.active ?? false),
);
async function onSettingsSave(
form: StravaIntegration | KomootIntegration,
key: "strava" | "komoot",
form: StravaIntegration | KomootIntegration | HammerheadIntegration,
key: "strava" | "komoot" | "hammerhead",
) {
try {
if (integration) {
@@ -49,6 +59,13 @@
integration = await integrations_create(newIntegration);
}
if (key == "komoot" || key == "hammerhead") {
let verified = await verifyLogin(key);
if (!verified) {
return;
}
}
show_toast({
text: $_("settings-saved"),
icon: "check",
@@ -104,7 +121,7 @@
}
show_toast({
text: "strava " + $_("integration-disabled"),
text: "Strava " + $_("integration-disabled"),
icon: "check",
type: "success",
});
@@ -116,23 +133,11 @@
return;
}
if (value) {
try {
const r = await fetch("/api/v1/integration/komoot/login", {
method: "GET",
});
if (!r.ok) {
throw Error();
}
} catch (e) {
komootToggleValue = false;
show_toast({
text: $_("error-logging-in-to-komoot"),
icon: "close",
type: "error",
});
let verified = await verifyLogin("komoot");
if (!verified) {
return;
}
integration.komoot.active = true;
} else {
integration.komoot.active = false;
@@ -141,7 +146,7 @@
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-updating-strava-integration"),
text: $_("error-updating-komoot-integration"),
icon: "close",
type: "error",
});
@@ -155,6 +160,65 @@
type: "success",
});
}
async function verifyLogin(integrationName: string): Promise<boolean> {
try {
const r = await fetch(
`/api/v1/integration/${integrationName}/login`,
{
method: "GET",
},
);
if (!r.ok) {
throw Error();
}
} catch (e) {
hammerheadToggleValue = false;
show_toast({
text: $_(`error-logging-in-to-${integrationName}`),
icon: "close",
type: "error",
});
return false;
}
return true;
}
async function onHammerheadToggle(value: boolean) {
if (!integration?.hammerhead) {
return;
}
if (value) {
let verified = await verifyLogin("hammerhead");
if (!verified) {
return;
}
integration.hammerhead.active = true;
} else {
integration.hammerhead.active = false;
}
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-updating-hammerhead-integration"),
icon: "close",
type: "error",
});
return;
}
show_toast({
text:
"Hammerhead " +
$_(`integration-${value ? "enabled" : "disabled"}`),
icon: "check",
type: "success",
});
}
</script>
<svelte:head>
@@ -167,7 +231,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<IntegrationCard
img="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
title="strava"
title="Strava"
description={$_("integration-description-strava")}
disabled={!integration?.strava}
active={stravaToggleValue}
@@ -183,6 +247,15 @@
onclick={() => komootSettingsModal.openModal()}
ontoggle={onKomootToggle}
></IntegrationCard>
<IntegrationCard
img={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite}
title="Hammerhead"
description={$_("integration-description-hammerhead")}
disabled={!integration?.hammerhead}
bind:active={hammerheadToggleValue}
onclick={() => hammerheadSettingsModal.openModal()}
ontoggle={onHammerheadToggle}
></IntegrationCard>
</div>
<StravaSettingsModal
@@ -196,3 +269,9 @@
{integration}
onsave={(form) => onSettingsSave(form, "komoot")}
></KomootSettingsModal>
<HammerheadSettingsModal
bind:this={hammerheadSettingsModal}
{integration}
onsave={(form) => onSettingsSave(form, "hammerhead")}
></HammerheadSettingsModal>