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:
910
db/integrations/hammerhead/hammerhead.go
Normal file
910
db/integrations/hammerhead/hammerhead.go
Normal 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
|
||||||
|
}
|
||||||
206
db/integrations/hammerhead/models.go
Normal file
206
db/integrations/hammerhead/models.go
Normal 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"`
|
||||||
|
}
|
||||||
83
db/main.go
83
db/main.go
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"pocketbase/commands"
|
"pocketbase/commands"
|
||||||
"pocketbase/federation"
|
"pocketbase/federation"
|
||||||
|
"pocketbase/integrations/hammerhead"
|
||||||
"pocketbase/integrations/komoot"
|
"pocketbase/integrations/komoot"
|
||||||
"pocketbase/integrations/strava"
|
"pocketbase/integrations/strava"
|
||||||
|
|
||||||
@@ -858,8 +859,9 @@ func updateIntegrationHandler() func(e *core.RecordEvent) error {
|
|||||||
}
|
}
|
||||||
func censorIntegrationSecrets(r *core.Record) error {
|
func censorIntegrationSecrets(r *core.Record) error {
|
||||||
secrets := map[string][]string{
|
secrets := map[string][]string{
|
||||||
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
||||||
"komoot": {"password"},
|
"komoot": {"password"},
|
||||||
|
"hammerhead": {"password"},
|
||||||
}
|
}
|
||||||
for key, secretKeys := range secrets {
|
for key, secretKeys := range secrets {
|
||||||
if integrationString := r.GetString(key); integrationString != "" {
|
if integrationString := r.GetString(key); integrationString != "" {
|
||||||
@@ -891,8 +893,9 @@ func encryptIntegrationSecrets(app core.App, r *core.Record) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
secrets := map[string][]string{
|
secrets := map[string][]string{
|
||||||
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
||||||
"komoot": {"password"},
|
"komoot": {"password"},
|
||||||
|
"hammerhead": {"password"},
|
||||||
}
|
}
|
||||||
|
|
||||||
original, _ := app.FindRecordById("integrations", r.Id)
|
original, _ := app.FindRecordById("integrations", r.Id)
|
||||||
@@ -1214,6 +1217,28 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
|||||||
return e.JSON(http.StatusOK, nil)
|
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 {
|
se.Router.GET("/integration/komoot/login", func(e *core.RequestEvent) error {
|
||||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||||
if len(encryptionKey) == 0 {
|
if len(encryptionKey) == 0 {
|
||||||
@@ -1389,9 +1414,59 @@ func registerCronJobs(app core.App) {
|
|||||||
fmt.Println(warning)
|
fmt.Println(warning)
|
||||||
app.Logger().Error(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 {
|
func bootstrapData(app core.App, client meilisearch.ServiceManager) error {
|
||||||
bootstrapCategories(app)
|
bootstrapCategories(app)
|
||||||
bootstrapMeilisearchConfig(client)
|
bootstrapMeilisearchConfig(client)
|
||||||
|
|||||||
41
db/migrations/1760706161_updated_integrations.go
Normal file
41
db/migrations/1760706161_updated_integrations.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
61
db/migrations/1760715417_updated_trails.go
Normal file
61
db/migrations/1760715417_updated_trails.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,31 +3,31 @@ title: Integrations
|
|||||||
description: How to set up third-party integrations with wanderer.
|
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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Setting Up the Integration
|
### Setting Up the Integration
|
||||||
|
|
||||||
1. Copy the **Client ID** and **Client Secret**.
|
1. Copy the **Client ID** and **Client Secret**.
|
||||||
2. Go to the integrations page in <span class="-tracking-[0.075em]">wanderer</span>'s settings.
|
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**.
|
4. Enter your **Client ID** and **Client Secret**.
|
||||||
5. Choose whether you want to sync routes, activities, or both.
|
5. Choose whether you want to sync routes, activities, or both.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
6. Save the settings and toggle the integration on.
|
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**.
|
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.
|
8. You will then be redirected back to <span class="-tracking-[0.075em]">wanderer</span>. The Strava integration is now active.
|
||||||
|
|
||||||
## komoot Integration
|
## 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>.
|
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
|
## 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).
|
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
|
:::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.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|||||||
15
web/src/lib/assets/svgs/logos/hammerhead_dark.svg
Normal file
15
web/src/lib/assets/svgs/logos/hammerhead_dark.svg
Normal 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 |
15
web/src/lib/assets/svgs/logos/hammerhead_white.svg
Normal file
15
web/src/lib/assets/svgs/logos/hammerhead_white.svg
Normal 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 |
@@ -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
|
||||||
|
>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
<Modal
|
<Modal
|
||||||
id="strava-settings-modal"
|
id="strava-settings-modal"
|
||||||
size="md:min-w-lg"
|
size="md:min-w-lg"
|
||||||
title={"strava " + $_("settings")}
|
title={"Strava " + $_("settings")}
|
||||||
bind:this={modal}
|
bind:this={modal}
|
||||||
>
|
>
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<div class="flex items-end relative gap-x-2 pt-2 border-t border-input-border">
|
<div class="flex items-end relative gap-x-2 pt-2 border-t border-input-border">
|
||||||
<Datepicker
|
<Datepicker
|
||||||
error={$errors.after}
|
error={$errors.after}
|
||||||
label={$_("after")}
|
label={$_("ignore-trails-before-date")}
|
||||||
bind:value={$formData.after}
|
bind:value={$formData.after}
|
||||||
></Datepicker>
|
></Datepicker>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import type { List } from "$lib/models/list";
|
import type { List } from "$lib/models/list";
|
||||||
import type { Trail } from "$lib/models/trail";
|
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 {
|
import {
|
||||||
lists_add_trail,
|
lists_add_trail,
|
||||||
lists_index,
|
lists_index,
|
||||||
@@ -17,12 +21,14 @@
|
|||||||
import { trail2gpx } from "$lib/util/gpx_util";
|
import { trail2gpx } from "$lib/util/gpx_util";
|
||||||
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import type { Snippet } from "svelte";
|
import { onMount, type Snippet } from "svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||||
import ConfirmModal from "../confirm_modal.svelte";
|
import ConfirmModal from "../confirm_modal.svelte";
|
||||||
import ListSearchModal from "../list/list_search_modal.svelte";
|
import ListSearchModal from "../list/list_search_modal.svelte";
|
||||||
import TrailExportModal from "./trail_export_modal.svelte";
|
import TrailExportModal from "./trail_export_modal.svelte";
|
||||||
|
import TrailSendModal from "./trail_send_modal.svelte";
|
||||||
import TrailShareModal from "./trail_share_modal.svelte";
|
import TrailShareModal from "./trail_share_modal.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,8 +46,56 @@
|
|||||||
let listSelectModal: ListSearchModal;
|
let listSelectModal: ListSearchModal;
|
||||||
let trailExportModal: TrailExportModal;
|
let trailExportModal: TrailExportModal;
|
||||||
let trailShareModal: TrailShareModal;
|
let trailShareModal: TrailShareModal;
|
||||||
|
let trailSendModal: TrailSendModal;
|
||||||
|
|
||||||
|
const hammerheadIntegration = $derived(
|
||||||
|
$integrations.find((integration) =>
|
||||||
|
Boolean(integration.hammerhead?.active)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
let lists: List[] = $state([]);
|
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);
|
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();
|
updateTrailsVisibility();
|
||||||
} else if (ddVal == "delete") {
|
} else if (ddVal == "delete") {
|
||||||
confirmModal.openModal();
|
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}
|
onsave={handleShareUpdate}
|
||||||
bind:this={trailShareModal}
|
bind:this={trailShareModal}
|
||||||
></TrailShareModal>
|
></TrailShareModal>
|
||||||
|
<TrailSendModal
|
||||||
|
bind:this={trailSendModal}
|
||||||
|
onsend={async (settings) => {
|
||||||
|
if (settings.integrationName === "hammerhead") {
|
||||||
|
await uploadToHammerhead();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></TrailSendModal>
|
||||||
|
|||||||
48
web/src/lib/components/trail/trail_send_modal.svelte
Normal file
48
web/src/lib/components/trail/trail_send_modal.svelte
Normal 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
|
||||||
|
>
|
||||||
@@ -149,6 +149,7 @@
|
|||||||
"error-exporting-trail": "Fehler beim Exportieren der Route",
|
"error-exporting-trail": "Fehler beim Exportieren der Route",
|
||||||
"error-generating-token": "",
|
"error-generating-token": "",
|
||||||
"error-liking-trail": "Error liking trail",
|
"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-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot",
|
||||||
"error-posting-comment": "Fehler beim Posten des Kommentars",
|
"error-posting-comment": "Fehler beim Posten des Kommentars",
|
||||||
"error-printing-map": "Fehler beim Drucken der Karte",
|
"error-printing-map": "Fehler beim Drucken der Karte",
|
||||||
@@ -157,7 +158,10 @@
|
|||||||
"error-saving-trail": "Fehler beim Speichern der Route",
|
"error-saving-trail": "Fehler beim Speichern der Route",
|
||||||
"error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration",
|
"error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration",
|
||||||
"error-updating-password": "Fehler beim Aktualisieren des Passworts",
|
"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",
|
"est-duration": "Gesch. Dauer",
|
||||||
"everyone-with-the-link": "Jeder mit dem Link",
|
"everyone-with-the-link": "Jeder mit dem Link",
|
||||||
"expiration": "",
|
"expiration": "",
|
||||||
@@ -196,6 +200,7 @@
|
|||||||
"get-started": "Los geht’s",
|
"get-started": "Los geht’s",
|
||||||
"grid": "Gitter",
|
"grid": "Gitter",
|
||||||
"grocery-store": "Lebensmittelgeschäft",
|
"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",
|
"heading": "Überschrift",
|
||||||
"height": "Höhe",
|
"height": "Höhe",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
@@ -211,11 +216,13 @@
|
|||||||
"hut": "Hütte",
|
"hut": "Hütte",
|
||||||
"hybrid": "Hybrid",
|
"hybrid": "Hybrid",
|
||||||
"icon": "Icon",
|
"icon": "Icon",
|
||||||
|
"ignore-trails-before-date": "Routen vor diesem Datum ignorieren",
|
||||||
"imperial": "Imperial",
|
"imperial": "Imperial",
|
||||||
"import": "Importieren",
|
"import": "Importieren",
|
||||||
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
|
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
|
||||||
"include-description": "Beschreibung übernehmen",
|
"include-description": "Beschreibung übernehmen",
|
||||||
"include-waypoints": "Wegpunkte einbeziehen",
|
"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-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.",
|
||||||
"integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.",
|
"integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.",
|
||||||
"integration-disabled": "Integration deaktiviert",
|
"integration-disabled": "Integration deaktiviert",
|
||||||
@@ -372,6 +379,7 @@
|
|||||||
"search-trails": "Route suchen",
|
"search-trails": "Route suchen",
|
||||||
"select-list": "Liste auswählen",
|
"select-list": "Liste auswählen",
|
||||||
"selected": "ausgewählt",
|
"selected": "ausgewählt",
|
||||||
|
"send-to": "Senden an...",
|
||||||
"set-private": "Verbergen",
|
"set-private": "Verbergen",
|
||||||
"set-public": "Veröffentlichen",
|
"set-public": "Veröffentlichen",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
@@ -417,7 +425,7 @@
|
|||||||
"statistics": "Statistiken",
|
"statistics": "Statistiken",
|
||||||
"stop-drawing": "Zeichnen beenden",
|
"stop-drawing": "Zeichnen beenden",
|
||||||
"stop-editing": "Bearbeiten 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",
|
"subway-stop": "U-Bahn Eingang",
|
||||||
"summit": "Gipfel",
|
"summit": "Gipfel",
|
||||||
"summit-book": "Gipfelbuch",
|
"summit-book": "Gipfelbuch",
|
||||||
@@ -429,6 +437,7 @@
|
|||||||
"top-speed": "Höchstgeschwindigkeit",
|
"top-speed": "Höchstgeschwindigkeit",
|
||||||
"tourism": "Tourismus",
|
"tourism": "Tourismus",
|
||||||
"trail": "{n, plural, =1 {Route} other {Routen}}",
|
"trail": "{n, plural, =1 {Route} other {Routen}}",
|
||||||
|
"trail-has-no-gpx": "Diese Route besitzt keine GPX Daten.",
|
||||||
"trail-copied-successfully": "Route erfolgreich kopiert",
|
"trail-copied-successfully": "Route erfolgreich kopiert",
|
||||||
"trail-not-in-list": "Trail gehört zu keiner Liste.",
|
"trail-not-in-list": "Trail gehört zu keiner Liste.",
|
||||||
"trail-not-shared": "Mit niemandem geteilt",
|
"trail-not-shared": "Mit niemandem geteilt",
|
||||||
@@ -442,6 +451,7 @@
|
|||||||
"upload-gpx": "GPX hochladen",
|
"upload-gpx": "GPX hochladen",
|
||||||
"upload-new-file": "Neue Datei hochladen",
|
"upload-new-file": "Neue Datei hochladen",
|
||||||
"uploaded": "hochgeladen",
|
"uploaded": "hochgeladen",
|
||||||
|
"uploaded-trail-to-hammerhead": "Route erfolgreich zu Hammerhead hochgeladen",
|
||||||
"use-hills": "Hügel einbeziehen",
|
"use-hills": "Hügel einbeziehen",
|
||||||
"use-roads": "Nutze Straßen",
|
"use-roads": "Nutze Straßen",
|
||||||
"username": "Nutzername",
|
"username": "Nutzername",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"error-exporting-trail": "Error exporting trail",
|
"error-exporting-trail": "Error exporting trail",
|
||||||
"error-generating-token": "Error generating token",
|
"error-generating-token": "Error generating token",
|
||||||
"error-liking-trail": "Error liking trail",
|
"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-logging-in-to-komoot": "Error logging in to komoot",
|
||||||
"error-posting-comment": "Error posting comment",
|
"error-posting-comment": "Error posting comment",
|
||||||
"error-printing-map": "Error printing map",
|
"error-printing-map": "Error printing map",
|
||||||
@@ -157,7 +158,10 @@
|
|||||||
"error-saving-trail": "Error saving trail",
|
"error-saving-trail": "Error saving trail",
|
||||||
"error-setting-up-integration": "Error setting up {provider} integration",
|
"error-setting-up-integration": "Error setting up {provider} integration",
|
||||||
"error-updating-password": "Error updating password",
|
"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",
|
"est-duration": "Est. duration",
|
||||||
"everyone-with-the-link": "Everyone with the link",
|
"everyone-with-the-link": "Everyone with the link",
|
||||||
"expiration": "Expiration",
|
"expiration": "Expiration",
|
||||||
@@ -196,6 +200,7 @@
|
|||||||
"get-started": "Get started",
|
"get-started": "Get started",
|
||||||
"grid": "Grid",
|
"grid": "Grid",
|
||||||
"grocery-store": "Grocery store",
|
"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",
|
"heading": "Heading",
|
||||||
"height": "Height",
|
"height": "Height",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
@@ -211,13 +216,15 @@
|
|||||||
"hut": "Hut",
|
"hut": "Hut",
|
||||||
"hybrid": "Hybrid",
|
"hybrid": "Hybrid",
|
||||||
"icon": "Icon",
|
"icon": "Icon",
|
||||||
|
"ignore-trails-before-date": "Ignore trails before this date",
|
||||||
"imperial": "Imperial",
|
"imperial": "Imperial",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
|
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
|
||||||
"include-description": "Include description",
|
"include-description": "Include description",
|
||||||
"include-waypoints": "Include waypoints",
|
"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-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-disabled": "integration disabled",
|
||||||
"integration-enabled": "integration enabled",
|
"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.",
|
"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",
|
"search-trails": "Search trails",
|
||||||
"select-list": "Select List",
|
"select-list": "Select List",
|
||||||
"selected": "selected",
|
"selected": "selected",
|
||||||
|
"send-to": "Send to...",
|
||||||
"set-private": "Set private",
|
"set-private": "Set private",
|
||||||
"set-public": "Set public",
|
"set-public": "Set public",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -417,7 +425,7 @@
|
|||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
"stop-drawing": "Stop drawing",
|
"stop-drawing": "Stop drawing",
|
||||||
"stop-editing": "Stop editing",
|
"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",
|
"subway-stop": "Subway entrance",
|
||||||
"summit": "Summit",
|
"summit": "Summit",
|
||||||
"summit-book": "Summit Book",
|
"summit-book": "Summit Book",
|
||||||
@@ -429,6 +437,7 @@
|
|||||||
"top-speed": "Top Speed",
|
"top-speed": "Top Speed",
|
||||||
"tourism": "Tourism",
|
"tourism": "Tourism",
|
||||||
"trail": "{n, plural, =1 {Trail} other {Trails}}",
|
"trail": "{n, plural, =1 {Trail} other {Trails}}",
|
||||||
|
"trail-has-no-gpx": "This trail has no GPX data.",
|
||||||
"trail-copied-successfully": "trail copied successfully",
|
"trail-copied-successfully": "trail copied successfully",
|
||||||
"trail-not-in-list": "Trail is not in any list",
|
"trail-not-in-list": "Trail is not in any list",
|
||||||
"trail-not-shared": "Not shared with anyone",
|
"trail-not-shared": "Not shared with anyone",
|
||||||
@@ -442,6 +451,7 @@
|
|||||||
"upload-gpx": "Upload GPX",
|
"upload-gpx": "Upload GPX",
|
||||||
"upload-new-file": "Upload new file",
|
"upload-new-file": "Upload new file",
|
||||||
"uploaded": "uploaded",
|
"uploaded": "uploaded",
|
||||||
|
"uploaded-trail-to-hammerhead": "Successfully uploaded trail to Hammerhead",
|
||||||
"use-hills": "Use hills",
|
"use-hills": "Use hills",
|
||||||
"use-roads": "Use Roads",
|
"use-roads": "Use Roads",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
|||||||
@@ -20,16 +20,27 @@ const KomootSchema = z.object({
|
|||||||
privacy: z.enum(["original", "settings"])
|
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({
|
const IntegrationCreateSchema = z.object({
|
||||||
user: z.string().length(15),
|
user: z.string().length(15),
|
||||||
strava: StravaSchema.optional(),
|
strava: StravaSchema.optional(),
|
||||||
komoot: KomootSchema.optional()
|
komoot: KomootSchema.optional(),
|
||||||
|
hammerhead: HammerheadSchema.optional(),
|
||||||
|
|
||||||
}) satisfies ZodType<Integration>
|
}) satisfies ZodType<Integration>
|
||||||
|
|
||||||
const IntegrationUpdateSchema = z.object({
|
const IntegrationUpdateSchema = z.object({
|
||||||
strava: StravaSchema.optional().nullable(),
|
strava: StravaSchema.optional().nullable(),
|
||||||
komoot: KomootSchema.optional().nullable()
|
komoot: KomootSchema.optional().nullable(),
|
||||||
|
hammerhead: HammerheadSchema.optional().nullable(),
|
||||||
}) satisfies ZodType<Partial<Integration>>
|
}) satisfies ZodType<Partial<Integration>>
|
||||||
|
|
||||||
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema };
|
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema };
|
||||||
|
|||||||
@@ -23,16 +23,26 @@ export interface KomootIntegration extends BaseIntegration {
|
|||||||
privacy: "original" | "settings"
|
privacy: "original" | "settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HammerheadIntegration extends BaseIntegration {
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
completed: boolean,
|
||||||
|
planned: boolean,
|
||||||
|
after?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export class Integration {
|
export class Integration {
|
||||||
id?: string;
|
id?: string;
|
||||||
user: string;
|
user: string;
|
||||||
strava?: StravaIntegration | null;
|
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.user = user;
|
||||||
this.strava = strava;
|
this.strava = strava;
|
||||||
this.komoot = komoot;
|
this.komoot = komoot;
|
||||||
|
this.hammerhead = hammerhead;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,21 @@ export async function integrations_create(integration: Integration) {
|
|||||||
return model;
|
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) {
|
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, {
|
let r = await f('/api/v1/integration/' + integration.id, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
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 IntegrationCard from "$lib/components/settings/integrations/integration_card.svelte";
|
||||||
import KomootSettingsModal from "$lib/components/settings/integrations/komoot_settings_modal.svelte";
|
import KomootSettingsModal from "$lib/components/settings/integrations/komoot_settings_modal.svelte";
|
||||||
import StravaSettingsModal from "$lib/components/settings/integrations/strava_settings_modal.svelte";
|
import StravaSettingsModal from "$lib/components/settings/integrations/strava_settings_modal.svelte";
|
||||||
import {
|
import {
|
||||||
Integration,
|
Integration,
|
||||||
|
type HammerheadIntegration,
|
||||||
type KomootIntegration,
|
type KomootIntegration,
|
||||||
type StravaIntegration
|
type StravaIntegration,
|
||||||
} from "$lib/models/integration.js";
|
} from "$lib/models/integration.js";
|
||||||
import {
|
import {
|
||||||
integrations_create,
|
integrations_create,
|
||||||
@@ -15,6 +17,9 @@
|
|||||||
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { _ } from "svelte-i18n";
|
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();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -33,9 +38,14 @@
|
|||||||
untrack(() => data.integration?.komoot?.active ?? false),
|
untrack(() => data.integration?.komoot?.active ?? false),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let hammerheadSettingsModal: HammerheadSettingsModal;
|
||||||
|
let hammerheadToggleValue: boolean = $state(
|
||||||
|
untrack(() => data.integration?.hammerhead?.active ?? false),
|
||||||
|
);
|
||||||
|
|
||||||
async function onSettingsSave(
|
async function onSettingsSave(
|
||||||
form: StravaIntegration | KomootIntegration,
|
form: StravaIntegration | KomootIntegration | HammerheadIntegration,
|
||||||
key: "strava" | "komoot",
|
key: "strava" | "komoot" | "hammerhead",
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (integration) {
|
if (integration) {
|
||||||
@@ -49,6 +59,13 @@
|
|||||||
integration = await integrations_create(newIntegration);
|
integration = await integrations_create(newIntegration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key == "komoot" || key == "hammerhead") {
|
||||||
|
let verified = await verifyLogin(key);
|
||||||
|
if (!verified) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
show_toast({
|
show_toast({
|
||||||
text: $_("settings-saved"),
|
text: $_("settings-saved"),
|
||||||
icon: "check",
|
icon: "check",
|
||||||
@@ -104,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
show_toast({
|
show_toast({
|
||||||
text: "strava " + $_("integration-disabled"),
|
text: "Strava " + $_("integration-disabled"),
|
||||||
icon: "check",
|
icon: "check",
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
@@ -116,23 +133,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value) {
|
if (value) {
|
||||||
try {
|
let verified = await verifyLogin("komoot");
|
||||||
const r = await fetch("/api/v1/integration/komoot/login", {
|
if (!verified) {
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!r.ok) {
|
|
||||||
throw Error();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
komootToggleValue = false;
|
|
||||||
show_toast({
|
|
||||||
text: $_("error-logging-in-to-komoot"),
|
|
||||||
icon: "close",
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
integration.komoot.active = true;
|
integration.komoot.active = true;
|
||||||
} else {
|
} else {
|
||||||
integration.komoot.active = false;
|
integration.komoot.active = false;
|
||||||
@@ -141,7 +146,7 @@
|
|||||||
integration = await integrations_update(integration);
|
integration = await integrations_update(integration);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
show_toast({
|
show_toast({
|
||||||
text: $_("error-updating-strava-integration"),
|
text: $_("error-updating-komoot-integration"),
|
||||||
icon: "close",
|
icon: "close",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
@@ -155,6 +160,65 @@
|
|||||||
type: "success",
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -167,7 +231,7 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<IntegrationCard
|
<IntegrationCard
|
||||||
img="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
|
img="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
|
||||||
title="strava"
|
title="Strava"
|
||||||
description={$_("integration-description-strava")}
|
description={$_("integration-description-strava")}
|
||||||
disabled={!integration?.strava}
|
disabled={!integration?.strava}
|
||||||
active={stravaToggleValue}
|
active={stravaToggleValue}
|
||||||
@@ -183,6 +247,15 @@
|
|||||||
onclick={() => komootSettingsModal.openModal()}
|
onclick={() => komootSettingsModal.openModal()}
|
||||||
ontoggle={onKomootToggle}
|
ontoggle={onKomootToggle}
|
||||||
></IntegrationCard>
|
></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>
|
</div>
|
||||||
|
|
||||||
<StravaSettingsModal
|
<StravaSettingsModal
|
||||||
@@ -196,3 +269,9 @@
|
|||||||
{integration}
|
{integration}
|
||||||
onsave={(form) => onSettingsSave(form, "komoot")}
|
onsave={(form) => onSettingsSave(form, "komoot")}
|
||||||
></KomootSettingsModal>
|
></KomootSettingsModal>
|
||||||
|
|
||||||
|
<HammerheadSettingsModal
|
||||||
|
bind:this={hammerheadSettingsModal}
|
||||||
|
{integration}
|
||||||
|
onsave={(form) => onSettingsSave(form, "hammerhead")}
|
||||||
|
></HammerheadSettingsModal>
|
||||||
|
|||||||
Reference in New Issue
Block a user