Files
wanderer/db/plugins/importer/importer.go
slothful-vassal 3b8f00fd26 feat: Refine trail category model (#1059)
* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
2026-06-29 03:16:26 +02:00

979 lines
29 KiB
Go

package importer
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"mime"
"net/http"
"net/url"
urlpath "path"
"path/filepath"
"slices"
"strings"
"time"
"pocketbase/pluginsystem"
"pocketbase/util"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/tkrajina/gpxgo/gpx"
)
type Options struct {
UserID string
ActorID string
DefaultPublic bool
CreateSummitLogForCompleted bool
CategoryMapping map[string]CategoryMappingValue
Manifest pluginsystem.Manifest
Policy pluginsystem.RequestPolicyContext
Auth map[string]any
}
// Result tells the sync loop whether a plugin item created a new trail or was
// skipped because the same provider/external id had already been imported.
type Result struct {
TrailID string
Created bool
Skipped bool
}
type CategoryMappingTarget struct {
CategoryID string
SubcategoryID string
}
type CategoryMappingValue struct {
Category string
Subcategory string
}
// ImportTrail is the boundary between plugin output and wanderer records. It
// validates the provider identity, deduplicates by trail_external_reference,
// stores the GPX/photos, maps GPX metrics onto the trail record, and creates the
// optional related waypoints and summit log.
func ImportTrail(ctx context.Context, app core.App, item pluginsystem.TrailImport, opts Options) (*Result, error) {
if item.Source.Provider == "" || item.Source.ExternalID == "" {
return nil, fmt.Errorf("source provider and externalId are required")
}
if existing, err := util.FindTrailByExternalReferenceForUser(app, opts.UserID, item.Source.Provider, item.Source.ExternalID); err != nil {
return nil, err
} else if existing != nil {
return &Result{TrailID: existing.Id, Skipped: true}, nil
}
gpxBytes, parsedGPX, err := decodeAndParseGPX(item.Track)
if err != nil {
return nil, err
}
gpxFile, err := filesystem.NewFileFromBytes(gpxBytes, safeGPXFileName(item.Name))
if err != nil {
return nil, err
}
collection, err := app.FindCollectionByNameOrId("trails")
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
metrics := metricsFromGPX(parsedGPX)
trackIndex := trackDistanceIndexFromGPX(parsedGPX)
applyProviderStart(&metrics, trackIndex, item.Metadata)
applyProviderMetrics(&metrics, item.Metadata)
public := publicFromPrivacy(item.Privacy, opts.DefaultPublic)
categoryTarget := categoryTargetForImport(app, item, opts.CategoryMapping)
date := dateFromImport(item, metrics)
mediaBudget := &pluginMediaBudget{}
photos := photoFiles(ctx, app, item.Photos, opts, mediaBudget)
record.Load(map[string]any{
"name": fallbackName(item.Name),
"description": item.Description,
"public": public,
"completed": item.Kind == "completed",
"distance": metrics.Distance,
"elevation_gain": metrics.ElevationGain,
"elevation_loss": metrics.ElevationLoss,
"duration": metrics.Duration,
"date": date,
"lat": metrics.StartLat,
"lon": metrics.StartLon,
"difficulty": "easy",
"category": categoryTarget.CategoryID,
"subcategory": categoryTarget.SubcategoryID,
"author": opts.ActorID,
})
record.Set("gpx", gpxFile)
if len(photos) > 0 {
record.Set("photos", photos)
}
if err := app.Save(record); err != nil {
return nil, err
}
if err := util.EnsureTrailExternalReference(app, record.Id, item.Source.Provider, item.Source.ExternalID, opts.Manifest.ID, ProviderCategoryFromImport(item)); err != nil {
return nil, err
}
if err := createWaypoints(ctx, app, item.Waypoints, opts, mediaBudget, record.Id, trackIndex); err != nil {
return nil, err
}
if opts.CreateSummitLogForCompleted && item.Kind == "completed" {
if err := createSummitLog(app, record.Id, opts.ActorID, date, metrics); err != nil {
return nil, err
}
}
return &Result{TrailID: record.Id, Created: true}, nil
}
type trailMetrics struct {
Distance float64
ElevationGain float64
ElevationLoss float64
Duration float64
StartLat float64
StartLon float64
StartTime time.Time
}
type geoPoint struct {
Lat float64
Lon float64
}
type trackDistanceIndex struct {
points []indexedTrackPoint
segments []indexedTrackSegment
}
type indexedTrackPoint struct {
point geoPoint
distance float64
}
type indexedTrackSegment struct {
start geoPoint
end geoPoint
startDistance float64
length float64
}
const maxProviderStartDistanceMeters = 1000
// decodeAndParseGPX keeps the importer strict for now: plugins must return GPX
// as base64 so the host can compute canonical trail metrics itself.
func decodeAndParseGPX(track pluginsystem.Track) ([]byte, *gpx.GPX, error) {
if track.Format != "gpx" {
return nil, nil, fmt.Errorf("unsupported track format %q", track.Format)
}
if track.ContentBase64 == "" {
return nil, nil, fmt.Errorf("track contentBase64 is required")
}
content, err := base64.StdEncoding.DecodeString(track.ContentBase64)
if err != nil {
return nil, nil, fmt.Errorf("decode GPX: %w", err)
}
parsed, err := gpx.Parse(bytes.NewReader(content))
if err != nil {
return nil, nil, fmt.Errorf("parse GPX: %w", err)
}
return content, parsed, nil
}
// metricsFromGPX derives fallback trail fields from the GPX. Provider metadata
// may override summary metrics and, when plausible, the displayed start point.
func metricsFromGPX(gpxData *gpx.GPX) trailMetrics {
uphillDownhill := gpxData.UphillDownhill()
movingData := gpxData.MovingData()
timeBounds := gpxData.TimeBounds()
metrics := trailMetrics{
Distance: gpxData.Length2D(),
ElevationGain: uphillDownhill.Uphill,
ElevationLoss: uphillDownhill.Downhill,
Duration: movingData.MovingTime + movingData.StoppedTime,
StartTime: timeBounds.StartTime,
}
for _, track := range gpxData.Tracks {
for _, segment := range track.Segments {
if len(segment.Points) == 0 {
continue
}
metrics.StartLat = segment.Points[0].Latitude
metrics.StartLon = segment.Points[0].Longitude
return metrics
}
}
return metrics
}
// applyProviderStart lets providers correct the displayed trail start when the
// provider's intended start is close to the imported GPX track. Implausible
// starts are ignored so broken metadata does not move trails off their geometry.
func applyProviderStart(metrics *trailMetrics, trackIndex trackDistanceIndex, metadata map[string]any) {
if metrics == nil || len(metadata) == 0 {
return
}
start, ok := providerStartFromMetadata(metadata)
if !ok || !providerStartNearTrack(trackIndex, start) {
return
}
metrics.StartLat = start.Lat
metrics.StartLon = start.Lon
}
func providerStartFromMetadata(metadata map[string]any) (geoPoint, bool) {
raw, ok := metadata["providerStart"]
if !ok {
return geoPoint{}, false
}
values, ok := raw.(map[string]any)
if !ok {
return geoPoint{}, false
}
lat, ok := floatMetadata(values, "lat")
if !ok {
lat, ok = floatMetadata(values, "latitude")
}
if !ok {
return geoPoint{}, false
}
lon, ok := floatMetadata(values, "lon")
if !ok {
lon, ok = floatMetadata(values, "longitude")
}
if !ok || lat < -90 || lat > 90 || lon < -180 || lon > 180 {
return geoPoint{}, false
}
return geoPoint{Lat: lat, Lon: lon}, true
}
func providerStartNearTrack(trackIndex trackDistanceIndex, start geoPoint) bool {
distance, ok := trackIndex.nearest(start)
return ok && distance.offTrack <= maxProviderStartDistanceMeters
}
type trackDistance struct {
fromStart float64
offTrack float64
}
func trackDistanceIndexFromGPX(gpxData *gpx.GPX) trackDistanceIndex {
index := trackDistanceIndex{}
if gpxData == nil {
return index
}
totalDistance := 0.0
for _, track := range gpxData.Tracks {
for _, segment := range track.Segments {
var previous geoPoint
hasPrevious := false
for _, point := range segment.Points {
current := geoPoint{Lat: point.Latitude, Lon: point.Longitude}
if !hasPrevious {
index.points = append(index.points, indexedTrackPoint{
point: current,
distance: totalDistance,
})
previous = current
hasPrevious = true
continue
}
length := util.HaversineDistanceMeters(previous.Lat, previous.Lon, current.Lat, current.Lon)
if length > 0 {
index.segments = append(index.segments, indexedTrackSegment{
start: previous,
end: current,
startDistance: totalDistance,
length: length,
})
totalDistance += length
}
index.points = append(index.points, indexedTrackPoint{
point: current,
distance: totalDistance,
})
previous = current
}
}
}
return index
}
func (index trackDistanceIndex) nearest(point geoPoint) (trackDistance, bool) {
var nearest trackDistance
found := false
for _, candidate := range index.points {
offTrack := util.HaversineDistanceMeters(point.Lat, point.Lon, candidate.point.Lat, candidate.point.Lon)
if !found || offTrack < nearest.offTrack {
nearest = trackDistance{fromStart: candidate.distance, offTrack: offTrack}
found = true
}
}
for _, segment := range index.segments {
offTrack, t := pointToSegmentProjectionMeters(point, segment.start, segment.end)
fromStart := segment.startDistance + segment.length*t
if !found || offTrack < nearest.offTrack {
nearest = trackDistance{fromStart: fromStart, offTrack: offTrack}
found = true
}
}
return nearest, found
}
func pointToSegmentProjectionMeters(point geoPoint, start geoPoint, end geoPoint) (float64, float64) {
const earthRadius = 6371000.0
latRad := point.Lat * math.Pi / 180
toXY := func(p geoPoint) (float64, float64) {
x := (p.Lon - point.Lon) * math.Pi / 180 * math.Cos(latRad) * earthRadius
y := (p.Lat - point.Lat) * math.Pi / 180 * earthRadius
return x, y
}
startX, startY := toXY(start)
endX, endY := toXY(end)
dx := endX - startX
dy := endY - startY
lengthSquared := dx*dx + dy*dy
if lengthSquared == 0 {
return math.Hypot(startX, startY), 0
}
t := -(startX*dx + startY*dy) / lengthSquared
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
closestX := startX + t*dx
closestY := startY + t*dy
return math.Hypot(closestX, closestY), t
}
// applyProviderMetrics lets plugins preserve provider-provided summary metrics
// where those values are more authoritative than values recalculated from a
// simplified/import GPX. GPX parsing remains mandatory and provides fallback
// metrics plus the start coordinate.
func applyProviderMetrics(metrics *trailMetrics, metadata map[string]any) {
if metrics == nil || len(metadata) == 0 {
return
}
if value, ok := positiveFloatMetadata(metadata, "distance"); ok {
metrics.Distance = value
}
if value, ok := positiveFloatMetadata(metadata, "elevationGain"); ok {
metrics.ElevationGain = value
}
if value, ok := positiveFloatMetadata(metadata, "elevationLoss"); ok {
metrics.ElevationLoss = value
}
if value, ok := positiveFloatMetadata(metadata, "duration"); ok {
metrics.Duration = value
}
}
func positiveFloatMetadata(metadata map[string]any, key string) (float64, bool) {
value, ok := floatMetadata(metadata, key)
return value, ok && value > 0
}
func floatMetadata(metadata map[string]any, key string) (float64, bool) {
switch value := metadata[key].(type) {
case float64:
return value, true
case float32:
floatValue := float64(value)
return floatValue, true
case int:
floatValue := float64(value)
return floatValue, true
case int64:
floatValue := float64(value)
return floatValue, true
case int32:
floatValue := float64(value)
return floatValue, true
case json.Number:
parsed, err := value.Float64()
return parsed, err == nil
default:
return 0, false
}
}
// publicFromPrivacy respects explicit provider privacy when present and falls
// back to the user's wanderer default when the plugin leaves privacy unset.
func publicFromPrivacy(privacy *string, defaultPublic bool) bool {
if privacy == nil || *privacy == "" {
return defaultPublic
}
return *privacy == "public"
}
// dateFromImport chooses the best available trail date: provider start time,
// GPX start time, then the import time.
func dateFromImport(item pluginsystem.TrailImport, metrics trailMetrics) time.Time {
if item.StartedAt != nil {
return *item.StartedAt
}
if !metrics.StartTime.IsZero() {
return metrics.StartTime
}
return time.Now()
}
// createWaypoints persists plugin-provided waypoints after the trail exists so
// they can reference the imported trail record.
func createWaypoints(ctx context.Context, app core.App, waypoints []pluginsystem.Waypoint, opts Options, mediaBudget *pluginMediaBudget, trailID string, trackIndex trackDistanceIndex) error {
if len(waypoints) == 0 {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
collection, err := app.FindCollectionByNameOrId("waypoints")
if err != nil {
return err
}
for _, waypoint := range waypoints {
record := core.NewRecord(collection)
icon := waypoint.Icon
if icon == "" {
icon = "circle"
}
distanceFromStart := 0.0
if distance, ok := trackIndex.nearest(geoPoint{Lat: waypoint.Lat, Lon: waypoint.Lon}); ok {
distanceFromStart = distance.fromStart
}
photos := photoFiles(ctx, app, waypoint.Photos, opts, mediaBudget)
record.Load(map[string]any{
"name": waypoint.Name,
"description": waypoint.Description,
"lat": waypoint.Lat,
"lon": waypoint.Lon,
"icon": icon,
"author": opts.ActorID,
"distance_from_start": distanceFromStart,
"trail": trailID,
})
if len(photos) > 0 {
record.Set("photos", photos)
}
if err := app.Save(record); err != nil {
return err
}
}
return nil
}
// photoFiles converts plugin photo descriptors into PocketBase file objects.
// Individual photo failures are logged and skipped so one broken media URL does
// not fail the whole trail import.
type pluginMediaBudget struct {
items int
bytes int64
}
func (b *pluginMediaBudget) remainingBytes() int64 {
remaining := util.DefaultPluginMaxImportMediaBytes - b.bytes
if remaining < util.DefaultPluginMediaMaxBytes {
return remaining
}
return util.DefaultPluginMediaMaxBytes
}
func photoFiles(ctx context.Context, app core.App, photos []pluginsystem.Photo, opts Options, budget *pluginMediaBudget) []*filesystem.File {
if len(photos) == 0 {
return nil
}
files := make([]*filesystem.File, 0, len(photos))
now := time.Now()
for _, photo := range photos {
if budget.items >= util.DefaultPluginMaxImportMediaItems {
app.Logger().Warn("skipping plugin photo because media item limit was reached", "limit", util.DefaultPluginMaxImportMediaItems)
continue
}
if err := ctx.Err(); err != nil {
app.Logger().Warn("skipping plugin photo because import context was cancelled", "error", err)
return files
}
if photo.Source.ExpiresAt != nil && photo.Source.ExpiresAt.Before(now) {
app.Logger().Warn("skipping expired plugin photo", "external_id", photo.ExternalID)
continue
}
maxBytes := budget.remainingBytes()
if maxBytes <= 0 {
app.Logger().Warn("skipping plugin photo because aggregate media byte limit was reached", "external_id", photo.ExternalID, "limit", util.DefaultPluginMaxImportMediaBytes)
continue
}
file, bytesRead, err := photoFile(ctx, photo, opts, maxBytes)
if err != nil {
app.Logger().Warn("skipping plugin photo", "external_id", photo.ExternalID, "error", err)
continue
}
if file != nil {
files = append(files, file)
budget.items++
budget.bytes += bytesRead
}
}
return files
}
// photoFile fetches one plugin-provided photo source. URL sources are validated
// before PocketBase performs the server-side download.
func photoFile(ctx context.Context, photo pluginsystem.Photo, opts Options, maxBytes int64) (*filesystem.File, int64, error) {
switch photo.Source.Type {
case "url":
if photo.Source.URL == "" {
return nil, 0, fmt.Errorf("photo URL is empty")
}
if err := validateRemoteMediaURLSyntax(photo.Source.URL); err != nil {
return nil, 0, err
}
fetched, err := util.FetchPublicURL(ctx, photo.Source.URL, maxBytes)
if err != nil {
return nil, 0, err
}
file, err := filesystem.NewFileFromBytes(fetched.Body, safeMediaFileName(photo.Filename, urlPathBase(fetched.FinalURL), fetched.ContentType, photo.ContentType))
return file, int64(len(fetched.Body)), err
case "connector":
fetched, err := fetchConnectorMedia(ctx, photo, opts, maxBytes)
if err != nil {
return nil, 0, err
}
file, err := filesystem.NewFileFromBytes(fetched.Body, safeMediaFileName(photo.Filename, urlPathBase(fetched.FinalURL), fetched.ContentType, photo.ContentType))
return file, int64(len(fetched.Body)), err
default:
return nil, 0, fmt.Errorf("unsupported photo source type %q", photo.Source.Type)
}
}
func fetchConnectorMedia(ctx context.Context, photo pluginsystem.Photo, opts Options, maxBytes int64) (*util.SafeFetchResult, error) {
if photo.Source.MediaRef == nil {
return nil, fmt.Errorf("connector mediaRef is required")
}
ref := *photo.Source.MediaRef
if ref.AssetID != "" && ref.Path == "" {
return nil, fmt.Errorf("mediaRef.assetId is metadata only; path is required")
}
target := pluginsystem.RequestTarget{
Type: "connector",
Connector: ref.Connector,
Path: ref.Path,
Query: ref.Query,
}
resolved, err := pluginsystem.ResolveRequestTarget(opts.Manifest, target, opts.Policy)
if err != nil {
return nil, err
}
if ref.Auth != "" {
if !resolved.Connector.SupportsMediaAuth {
return nil, fmt.Errorf("connector %q does not support media auth", ref.Connector)
}
if len(resolved.Connector.Auth) > 0 && !slices.Contains(resolved.Connector.Auth, ref.Auth) {
return nil, fmt.Errorf("auth context %q is not permitted for connector %q", ref.Auth, ref.Connector)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolved.URL.String(), nil)
if err != nil {
return nil, err
}
if err := pluginsystem.InjectRequestAuthForContext(opts.Manifest, opts.Auth, ref.Auth, req); err != nil {
return nil, err
}
var storageRedirect *storageRedirectTarget
client, err := util.ConnectorHTTPClient(util.ConnectorHTTPPolicy{
BaseURL: resolved.Connector.BaseURL,
AllowPrivate: resolved.Connector.AllowPrivate,
TLSMode: resolved.Connector.TLS.Mode,
TLSCABundle: resolved.Connector.TLS.CABundle,
}, func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
previous := resolved.URL
if len(via) > 0 {
previous = via[len(via)-1].URL
}
if err := pluginsystem.ValidateConnectorRedirect(resolved.Connector, previous, req.URL); err == nil {
return nil
}
origin, err := pluginsystem.ConnectorStorageRedirectOrigin(resolved.Connector, previous, req.URL)
if err != nil {
return err
}
stripConnectorAuth(req, opts.Manifest, ref.Auth)
storageRedirect = &storageRedirectTarget{
URL: req.URL.String(),
Origin: origin,
}
return http.ErrUseLastResponse
})
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if storageRedirect != nil && resp.StatusCode >= 300 && resp.StatusCode < 400 {
return fetchStorageRedirectMedia(ctx, *storageRedirect, maxBytes)
}
body, err := util.ReadBoundedForPlugin(resp.Body, maxBytes)
if err != nil {
return nil, err
}
return &util.SafeFetchResult{Body: body, ContentType: resp.Header.Get("Content-Type"), FinalURL: resp.Request.URL.String()}, nil
}
type storageRedirectTarget struct {
URL string
Origin pluginsystem.ResolvedConnectorOrigin
}
func fetchStorageRedirectMedia(ctx context.Context, redirect storageRedirectTarget, maxBytes int64) (*util.SafeFetchResult, error) {
storageConnector := pluginsystem.ResolvedConnectorTarget{
Name: redirect.Origin.Name,
BaseURL: redirect.Origin.BaseURL,
BasePath: redirect.Origin.BasePath,
AllowPrivate: redirect.Origin.AllowPrivate,
TLS: redirect.Origin.TLS,
AllowedPathPrefixes: []string{"/"},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, redirect.URL, nil)
if err != nil {
return nil, err
}
client, err := util.ConnectorHTTPClient(util.ConnectorHTTPPolicy{
BaseURL: redirect.Origin.BaseURL,
AllowPrivate: redirect.Origin.AllowPrivate,
TLSMode: redirect.Origin.TLS.Mode,
TLSCABundle: redirect.Origin.TLS.CABundle,
}, func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
previous := req.URL
if len(via) > 0 {
previous = via[len(via)-1].URL
}
return pluginsystem.ValidateConnectorRedirect(storageConnector, previous, req.URL)
})
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := util.ReadBoundedForPlugin(resp.Body, maxBytes)
if err != nil {
return nil, err
}
return &util.SafeFetchResult{Body: body, ContentType: resp.Header.Get("Content-Type"), FinalURL: resp.Request.URL.String()}, nil
}
func stripConnectorAuth(req *http.Request, manifest pluginsystem.Manifest, authName string) {
req.Header.Del(pluginsystem.AuthHeaderAuthorization)
if authName == "" {
return
}
authContext, ok := manifest.Auth.Contexts[authName]
if !ok {
return
}
if authContext.Name != "" {
req.Header.Del(authContext.Name)
req.URL.RawQuery = removeRawQueryParamOrdered(req.URL.RawQuery, authContext.Name)
}
if authContext.SecretField != "" {
req.Header.Del(authContext.SecretField)
req.URL.RawQuery = removeRawQueryParamOrdered(req.URL.RawQuery, authContext.SecretField)
}
}
func validateRemoteMediaURLSyntax(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid media URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("unsupported media URL scheme %q", parsed.Scheme)
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("media URL has no host")
}
return nil
}
func urlPathBase(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
return urlpath.Base(parsed.Path)
}
func removeRawQueryParamOrdered(rawQuery string, name string) string {
if rawQuery == "" || name == "" {
return rawQuery
}
parts := strings.Split(rawQuery, "&")
kept := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
rawName := part
if idx := strings.Index(rawName, "="); idx >= 0 {
rawName = rawName[:idx]
}
decodedName, err := url.QueryUnescape(rawName)
if err == nil && decodedName == name {
continue
}
kept = append(kept, part)
}
return strings.Join(kept, "&")
}
// createSummitLog mirrors completed imported trails into summit_logs when the
// user has enabled that compatibility option.
func createSummitLog(app core.App, trailID string, actorID string, date time.Time, metrics trailMetrics) error {
collection, err := app.FindCollectionByNameOrId("summit_logs")
if err != nil {
return err
}
record := core.NewRecord(collection)
record.Load(map[string]any{
"distance": metrics.Distance,
"elevation_gain": metrics.ElevationGain,
"elevation_loss": metrics.ElevationLoss,
"duration": metrics.Duration,
"date": date,
"author": actorID,
"trail": trailID,
})
return app.Save(record)
}
func categoryTargetForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]CategoryMappingValue) CategoryMappingTarget {
if categoryTarget, matched := CategoryTargetFromProviderMapping(app, ProviderCategoryFromImport(item), mapping); matched {
return categoryTarget
}
return categoryTargetForActivityType(app, item.ActivityType)
}
func categoryIDForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]CategoryMappingValue) string {
return categoryTargetForImport(app, item, mapping).CategoryID
}
func ProviderCategoryFromImport(item pluginsystem.TrailImport) string {
value, _ := item.Metadata["providerCategory"].(string)
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
value, _ = item.Metadata["sourceSport"].(string)
return strings.TrimSpace(value)
}
func CategoryFromProviderMapping(app core.App, providerCategory string, mapping map[string]CategoryMappingValue) (string, bool) {
target, matched := CategoryTargetFromProviderMapping(app, providerCategory, mapping)
return target.CategoryID, matched
}
func CategoryTargetFromProviderMapping(app core.App, providerCategory string, mapping map[string]CategoryMappingValue) (CategoryMappingTarget, bool) {
providerCategory = strings.TrimSpace(providerCategory)
if providerCategory == "" || len(mapping) == 0 {
return CategoryMappingTarget{}, false
}
mappingTarget, matched := mapping[providerCategory]
if !matched {
return CategoryMappingTarget{}, false
}
if mappingTarget.Category == "" && mappingTarget.Subcategory == "" {
return CategoryMappingTarget{}, true
}
return resolveCategoryMappingTarget(app, mappingTarget)
}
// categoryIDForActivityType maps common provider activity labels to wanderer's
// built-in categories. Unknown labels intentionally leave the category empty.
func categoryIDForActivityType(app core.App, activityType string) string {
return categoryTargetForActivityType(app, activityType).CategoryID
}
func categoryTargetForActivityType(app core.App, activityType string) CategoryMappingTarget {
name := categoryNameForActivityType(activityType)
if name == "" {
return CategoryMappingTarget{}
}
target, matched := resolveCategoryMappingTarget(app, CategoryMappingValue{Category: name})
if !matched {
return CategoryMappingTarget{}
}
return target
}
func categoryNameForActivityType(activityType string) string {
categoryMap := map[string]string{
"hiking": "Hiking",
"hike": "Hiking",
"walking": "Walking",
"walk": "Walking",
"running": "Running",
"run": "Running",
"virtualrun": "Running",
"trailrun": "Running",
"jogging": "Running",
"biking": "Biking",
"cycling": "Biking",
"ride": "Biking",
"mtb": "Biking",
"skiing": "Skiing",
"canoeing": "Canoeing",
"climbing": "Climbing",
}
return categoryMap[strings.ToLower(strings.TrimSpace(activityType))]
}
func resolveCategoryMappingTarget(app core.App, target CategoryMappingValue) (CategoryMappingTarget, bool) {
categoryNameOrID := strings.TrimSpace(target.Category)
subcategoryNameOrID := strings.TrimSpace(target.Subcategory)
if categoryNameOrID == "" && subcategoryNameOrID == "" {
return CategoryMappingTarget{}, false
}
if subcategoryNameOrID != "" {
if subcategory, err := app.FindRecordById("subcategories", subcategoryNameOrID); err == nil && subcategory != nil {
categoryID := subcategory.GetString("category")
if categoryID == "" {
return CategoryMappingTarget{}, false
}
return CategoryMappingTarget{CategoryID: categoryID, SubcategoryID: subcategory.Id}, true
}
category, subcategory, err := util.ResolveCategoryAndSubcategoryByNormalizedNames(app, categoryNameOrID, subcategoryNameOrID)
if err == nil && category != nil && subcategory != nil {
return CategoryMappingTarget{CategoryID: category.Id, SubcategoryID: subcategory.Id}, true
}
return CategoryMappingTarget{}, false
}
if category, err := app.FindRecordById("categories", categoryNameOrID); err == nil && category != nil {
return CategoryMappingTarget{CategoryID: category.Id}, true
}
if subcategory, err := app.FindRecordById("subcategories", categoryNameOrID); err == nil && subcategory != nil {
categoryID := subcategory.GetString("category")
if categoryID == "" {
return CategoryMappingTarget{}, false
}
return CategoryMappingTarget{CategoryID: categoryID, SubcategoryID: subcategory.Id}, true
}
category, _ := util.FindCategoryByNormalizedName(app, categoryNameOrID)
if category != nil {
return CategoryMappingTarget{CategoryID: category.Id}, true
}
return CategoryMappingTarget{}, false
}
func fallbackName(name string) string {
if strings.TrimSpace(name) != "" {
return name
}
return "Imported trail"
}
// safeGPXFileName turns provider trail names into filesystem-safe GPX filenames.
func safeGPXFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "imported-trail"
}
name = filepath.Base(name)
name = strings.TrimSuffix(name, filepath.Ext(name))
name = strings.Map(func(r rune) rune {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
return '-'
default:
return r
}
}, name)
return name + ".gpx"
}
// safeMediaFileName picks the first safe candidate filename and adds a best
// effort extension when providers only expose a content type.
func safeMediaFileName(candidates ...string) string {
filename := ""
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || strings.Contains(candidate, "/") {
continue
}
base := filepath.Base(candidate)
if base == "." || base == ".." {
continue
}
filename = candidate
break
}
if filename == "" {
filename = "photo"
}
filename = filepath.Base(filename)
filename = strings.Map(func(r rune) rune {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
return '-'
default:
return r
}
}, filename)
if ext := filepath.Ext(filename); ext == "" || ext == "." {
filename += extensionFromContentTypes(candidates...)
}
return filename
}
func extensionFromContentTypes(candidates ...string) string {
for _, candidate := range candidates {
if extensions, err := mime.ExtensionsByType(strings.TrimSpace(candidate)); err == nil && len(extensions) > 0 {
return extensions[0]
}
}
return ".jpg"
}