diff --git a/README.md b/README.md index ff91d98..7da850b 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ All configuration is done via environment variables. | `ACCOUNT__N__MARKERS` | | No | Comma-separated `marker:functionality` rules assigning behavior to point markers (see [Marker functionalities](#marker-functionalities)) | | `ACCOUNT__N__SPLIT_MARKER_POSITION` | `start` | No | For the `split` functionality: where the marked point goes, `start` of the new track or `end` of the previous one | | `ACCOUNT__N__SPLIT_MODE` | `tracks` | No | For the `split` functionality: `tracks` (all tracks in one file) or `files` (one upload per track) | +| `TARGET__DAWARICH__EMIT_TRACKER_ID` | `false` | No | Tag each track with a stable `tracker_id` so Dawarich keeps split tracks separate (see [Keeping split tracks separate in Dawarich](#keeping-split-tracks-separate-in-dawarich)) | | `CLIENT__N__ID` | | Yes | Client identifier | | `CLIENT__N__TOKEN` | | Yes if not TOKEN_FILE | Auth token (inline) | | `CLIENT__N__TOKEN_FILE` | | Yes if not TOKEN | Auth token (file path) | @@ -229,11 +230,20 @@ point of the new track, `end` keeps it as the last point of the previous one. `ACCOUNT__N__SPLIT_MODE` controls how the split tracks are delivered: - `tracks` (default): all tracks are written into a single file. -- `files`: each track is uploaded as its own file. Some targets, **including - Dawarich**, treat one uploaded file as a single track and rebuild their own - segmentation from the points; for those you need `files` so the split legs - actually appear as separate tracks. The output filenames are suffixed - (`track-1.geojson`, `track-2.geojson`, …). +- `files`: each track is uploaded as its own file, with suffixed filenames + (`track-1.geojson`, `track-2.geojson`, …). Splitting into files does not by + itself keep tracks separate in Dawarich, which re-segments the points by time + gap; see [Keeping split tracks separate in Dawarich](#keeping-split-tracks-separate-in-dawarich). + +### Keeping split tracks separate in Dawarich + +Dawarich rebuilds tracks from the uploaded points by splitting on time gaps, so +split legs that are close in time get merged back together. Set +`TARGET__DAWARICH__EMIT_TRACKER_ID=true` to tag each track with a stable `tracker_id`; +Dawarich groups points into tracks by that id and keeps the split legs separate. +This works in either `SPLIT_MODE`, and regardless of the uploaded file format: +the Dawarich target always forwards points as GeoJSON, so the `tracker_id` is +what keeps the legs apart. ## Supported targets diff --git a/server/internal/config/config.go b/server/internal/config/config.go index cd54acf..56dc55e 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -4,10 +4,12 @@ import ( "fmt" "os" "reflect" + "strconv" "strings" "time" "github.com/Quadrubo/tracksync/server/internal/converter" + "github.com/Quadrubo/tracksync/server/internal/target" "github.com/go-playground/validator/v10" "github.com/spf13/viper" ) @@ -59,6 +61,8 @@ type Config struct { PassthroughConversion bool Accounts []Account `validate:"required,dive"` Clients []Client `validate:"required,dive"` + // TargetConfigs holds each target type's own config, keyed by type name. + TargetConfigs map[string]any } type Account struct { @@ -105,14 +109,28 @@ func Load(envFile string) (*Config, error) { return nil, fmt.Errorf("config: MAX_UPLOAD_SIZE must be positive") } + accounts, err := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + if err != nil { + return nil, err + } + clients, err := parseGroup[Client](v, "CLIENT", "ID") + if err != nil { + return nil, err + } + targetConfigs, err := parseTargetConfigs(v) + if err != nil { + return nil, err + } + cfg := &Config{ Port: v.GetString("PORT"), StateDB: v.GetString("STATE_DB"), TargetTimeout: targetTimeout, MaxUploadSize: maxUploadSize << 20, // MB to bytes PassthroughConversion: v.GetBool("PASSTHROUGH_CONVERSION"), - Accounts: parseGroup[Account](v, "ACCOUNT", "DEVICE_ID"), - Clients: parseGroup[Client](v, "CLIENT", "ID"), + Accounts: accounts, + Clients: clients, + TargetConfigs: targetConfigs, } if err := cfg.validate(); err != nil { @@ -139,10 +157,8 @@ func (cfg *Config) validate() error { } // parseGroup reads indexed env var groups (e.g. ACCOUNT__0__*, ACCOUNT__1__*) -// into a slice of T. Fields are mapped via `env` struct tags, with optional -// `default` tags. Slice fields ([]string) are split on commas. -// Iteration stops when the sentinel key is empty. -func parseGroup[T any](v *viper.Viper, prefix, sentinel string) []T { +// into a slice of T, stopping at the first index whose sentinel key is empty. +func parseGroup[T any](v *viper.Viper, prefix, sentinel string) ([]T, error) { var items []T rt := reflect.TypeOf((*T)(nil)).Elem() @@ -151,38 +167,91 @@ func parseGroup[T any](v *viper.Viper, prefix, sentinel string) []T { if v.GetString(p+sentinel) == "" { break } - item := reflect.New(rt).Elem() - for j := 0; j < rt.NumField(); j++ { - f := rt.Field(j) - key := f.Tag.Get("env") - if key == "" { - continue - } - val := v.GetString(p + key) - if val == "" { - val = f.Tag.Get("default") - } - switch f.Type.Kind() { - case reflect.String: - item.Field(j).SetString(val) - case reflect.Slice: - if val != "" { - var parts []string - for _, s := range strings.Split(val, ",") { - if s = strings.TrimSpace(s); s != "" { - parts = append(parts, s) - } - } - item.Field(j).Set(reflect.ValueOf(parts)) + if err := fillStruct(v, p, item); err != nil { + return nil, err + } + items = append(items, item.Interface().(T)) + } + return items, nil +} + +// parseTargetConfigs fills each registered target type's config from its +// TARGET____* env vars, keyed by target type. +func parseTargetConfigs(v *viper.Viper) (map[string]any, error) { + configs := map[string]any{} + for typeName, prototype := range target.ConfigPrototypes() { + prefix := "TARGET__" + strings.ToUpper(typeName) + "__" + c, err := parseTargetConfig(v, prefix, prototype) + if err != nil { + return nil, err + } + configs[typeName] = c + } + return configs, nil +} + +// parseTargetConfig fills a fresh copy of prototype from env vars under prefix. +func parseTargetConfig(v *viper.Viper, prefix string, prototype any) (any, error) { + rv := reflect.New(reflect.TypeOf(prototype)).Elem() + if err := fillStruct(v, prefix, rv); err != nil { + return nil, err + } + return rv.Interface(), nil +} + +// fillStruct populates struct rv from env vars under prefix, mapping fields via +// `env` tags with optional `default` tags. Fields without an `env` tag are +// skipped; []string fields are split on commas. +func fillStruct(v *viper.Viper, prefix string, rv reflect.Value) error { + rt := rv.Type() + for j := 0; j < rt.NumField(); j++ { + f := rt.Field(j) + key := f.Tag.Get("env") + if key == "" { + continue + } + val := v.GetString(prefix + key) + if val == "" { + val = f.Tag.Get("default") + } + if err := setField(rv.Field(j), prefix+key, val); err != nil { + return err + } + } + return nil +} + +// setField assigns val to a field by kind; slices split on commas. name is the +// env key, used for error messages. An unsupported field kind means a +// misdeclared config struct, so it panics rather than returning an error. +func setField(field reflect.Value, name, val string) error { + switch field.Kind() { + case reflect.String: + field.SetString(val) + case reflect.Bool: + if val == "" { + return nil + } + b, err := strconv.ParseBool(strings.TrimSpace(val)) + if err != nil { + return fmt.Errorf("config: %s: invalid boolean value %q", name, val) + } + field.SetBool(b) + case reflect.Slice: + if val != "" { + var parts []string + for _, s := range strings.Split(val, ",") { + if s = strings.TrimSpace(s); s != "" { + parts = append(parts, s) } - default: - panic(fmt.Sprintf("parseGroup: unsupported field type %s for %s.%s", f.Type.Kind(), rt.Name(), f.Name)) } + field.Set(reflect.ValueOf(parts)) } - items = append(items, item.Interface().(T)) + default: + panic(fmt.Sprintf("config: %s: unsupported field kind %s", name, field.Kind())) } - return items + return nil } // ResolveToken returns the client's auth token. diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index dfbeee8..b17de56 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -20,7 +20,8 @@ func TestParseGroup_Accounts(t *testing.T) { v.Set("ACCOUNT__1__TARGET_URL", "http://localhost:3001") v.Set("ACCOUNT__1__API_KEY_FILE", "/tmp/key") - accounts := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + accounts, err := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + require.NoError(t, err) require.Len(t, accounts, 2) assert.Equal(t, "dev-1", accounts[0].DeviceID) @@ -35,7 +36,8 @@ func TestParseGroup_DefaultTargetType(t *testing.T) { v.Set("ACCOUNT__0__TARGET_URL", "http://localhost:3000") v.Set("ACCOUNT__0__API_KEY", "key") - accounts := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + accounts, err := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + require.NoError(t, err) require.Len(t, accounts, 1) assert.Equal(t, "dawarich", accounts[0].TargetType) @@ -49,7 +51,8 @@ func TestParseGroup_SplitConfig(t *testing.T) { v.Set("ACCOUNT__0__MARKERS", "C:split, D:split") v.Set("ACCOUNT__0__SPLIT_MARKER_POSITION", "end") - accounts := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + accounts, err := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + require.NoError(t, err) require.Len(t, accounts, 1) assert.Equal(t, []string{"C:split", "D:split"}, accounts[0].Markers) @@ -62,7 +65,8 @@ func TestParseGroup_SplitDefaults(t *testing.T) { v.Set("ACCOUNT__0__TARGET_URL", "http://localhost:3000") v.Set("ACCOUNT__0__API_KEY", "key") - accounts := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + accounts, err := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + require.NoError(t, err) require.Len(t, accounts, 1) assert.Nil(t, accounts[0].Markers, "no markers by default") @@ -132,7 +136,8 @@ func TestParseGroup_Clients(t *testing.T) { v.Set("CLIENT__0__TOKEN", "tok") v.Set("CLIENT__0__ALLOWED_DEVICES", "dev-1, dev-2 , dev-3") - clients := parseGroup[Client](v, "CLIENT", "ID") + clients, err := parseGroup[Client](v, "CLIENT", "ID") + require.NoError(t, err) require.Len(t, clients, 1) assert.Equal(t, "laptop", clients[0].ID) @@ -149,7 +154,8 @@ func TestParseGroup_StopsAtGap(t *testing.T) { v.Set("ACCOUNT__2__TARGET_URL", "http://localhost") v.Set("ACCOUNT__2__API_KEY", "key") - accounts := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + accounts, err := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID") + require.NoError(t, err) assert.Len(t, accounts, 1, "should stop at gap in indices") } @@ -242,3 +248,31 @@ func TestCanUpload_Empty(t *testing.T) { c := &Client{} assert.False(t, c.CanUpload("anything")) } + +type targetCfgFixture struct { + Flag bool `env:"FLAG"` +} + +func TestParseTargetConfig_Bool(t *testing.T) { + v := viper.New() + v.Set("TARGET__TEST__FLAG", "true") + + c, err := parseTargetConfig(v, "TARGET__TEST__", targetCfgFixture{}) + require.NoError(t, err) + assert.True(t, c.(targetCfgFixture).Flag) +} + +func TestParseTargetConfig_DefaultsZero(t *testing.T) { + c, err := parseTargetConfig(viper.New(), "TARGET__TEST__", targetCfgFixture{}) + require.NoError(t, err) + assert.False(t, c.(targetCfgFixture).Flag) +} + +func TestParseTargetConfig_InvalidBool(t *testing.T) { + v := viper.New() + v.Set("TARGET__TEST__FLAG", "yes") + + _, err := parseTargetConfig(v, "TARGET__TEST__", targetCfgFixture{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "FLAG") +} diff --git a/server/internal/converter/converter.go b/server/internal/converter/converter.go index 78a7996..866c429 100644 --- a/server/internal/converter/converter.go +++ b/server/internal/converter/converter.go @@ -12,6 +12,12 @@ type OutputFile struct { Filename string } +// TrackTransformer lets a target shape the split tracks for a given output +// format before serialization. It returns whether it changed anything. +type TrackTransformer interface { + TransformTracks(format string, files [][]Track) (changed bool) +} + // Convert parses data in sourceFormat, applies markers, selects the best target // format from acceptedFormats, and serializes the tracks into one or more output // files. @@ -19,7 +25,7 @@ type OutputFile struct { // When passthrough is true and sourceFormat matches the best target format, the // original data is returned unchanged; otherwise it is re-serialized. With // markers.SplitMode == "files" each track is serialized into its own file. -func Convert(sourceFormat string, data []byte, acceptedFormats []string, originalFilename string, passthrough bool, markers MarkerOptions) ([]OutputFile, error) { +func Convert(sourceFormat string, data []byte, acceptedFormats []string, originalFilename string, passthrough bool, markers MarkerOptions, transformer TrackTransformer) ([]OutputFile, error) { parser, ok := GetParser(sourceFormat) if !ok { return nil, fmt.Errorf("no parser for format %q", sourceFormat) @@ -32,7 +38,6 @@ func Convert(sourceFormat string, data []byte, acceptedFormats []string, origina result := applyMarkers(tracks, markers) - // Determine which fields the parsed tracks actually contain. usedFields := mergeUsedFields(result.Tracks()) bestFormat := selectBestFormat(usedFields, acceptedFormats) @@ -40,6 +45,11 @@ func Convert(sourceFormat string, data []byte, acceptedFormats []string, origina return nil, fmt.Errorf("no serializer available for any accepted format: %v", acceptedFormats) } + // After format selection so the transformer can skip formats that can't carry its changes. + if transformer != nil && transformer.TransformTracks(bestFormat, result.Files) { + result.Modified = true + } + // Passthrough only when nothing was restructured; a split rewrites the tracks. if passthrough && bestFormat == sourceFormat && !result.Modified { return []OutputFile{{Data: data, Format: bestFormat, Filename: originalFilename}}, nil diff --git a/server/internal/converter/converter_test.go b/server/internal/converter/converter_test.go index dc9610e..2cf8c00 100644 --- a/server/internal/converter/converter_test.go +++ b/server/internal/converter/converter_test.go @@ -112,7 +112,7 @@ func TestConvert_GPXPassthrough(t *testing.T) { `) // GPX → GPX with passthrough enabled: return original bytes - outs, err := Convert("gpx_1.1", gpxData, []string{"gpx_1.1", "geojson"}, "track.gpx", true, MarkerOptions{}) + outs, err := Convert("gpx_1.1", gpxData, []string{"gpx_1.1", "geojson"}, "track.gpx", true, MarkerOptions{}, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, "gpx_1.1", outs[0].Format) @@ -133,7 +133,7 @@ func TestConvert_PassthroughSkippedWhenSplitOccurs(t *testing.T) { outs, err := Convert("columbus-csv", csvData, []string{"gpx_1.1"}, "track.csv", true, MarkerOptions{ Rules: []MarkerRule{{Marker: "C", Functionality: MarkerSplit}}, SplitMarkerPosition: "start", - }) + }, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, 2, strings.Count(string(outs[0].Data), ""), "split must produce two tracks, not passthrough") @@ -152,7 +152,7 @@ func TestConvert_PassthroughWhenConfiguredSplitDoesNotFire(t *testing.T) { outs, err := Convert("gpx_1.1", gpxData, []string{"gpx_1.1", "geojson"}, "track.gpx", true, MarkerOptions{ Rules: []MarkerRule{{Marker: "C", Functionality: MarkerSplit}}, SplitMarkerPosition: "start", - }) + }, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, "gpx_1.1", outs[0].Format) @@ -168,7 +168,7 @@ func TestConvert_GPXReserialized(t *testing.T) { `) // GPX → GPX without passthrough: re-serialized - outs, err := Convert("gpx_1.1", gpxData, []string{"gpx_1.1", "geojson"}, "track.gpx", false, MarkerOptions{}) + outs, err := Convert("gpx_1.1", gpxData, []string{"gpx_1.1", "geojson"}, "track.gpx", false, MarkerOptions{}, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, "gpx_1.1", outs[0].Format) @@ -187,7 +187,7 @@ func TestConvert_GPXToGeoJSON_WhenSpeedInTarget(t *testing.T) { `) // GeoJSON listed first: wins the tie - outs, err := Convert("gpx_1.1", gpxData, []string{"geojson", "gpx_1.1"}, "track.gpx", false, MarkerOptions{}) + outs, err := Convert("gpx_1.1", gpxData, []string{"geojson", "gpx_1.1"}, "track.gpx", false, MarkerOptions{}, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, "geojson", outs[0].Format) @@ -196,13 +196,13 @@ func TestConvert_GPXToGeoJSON_WhenSpeedInTarget(t *testing.T) { } func TestConvert_NoParser(t *testing.T) { - _, err := Convert("unknown", []byte("data"), []string{"gpx_1.1"}, "f.txt", false, MarkerOptions{}) + _, err := Convert("unknown", []byte("data"), []string{"gpx_1.1"}, "f.txt", false, MarkerOptions{}, nil) assert.ErrorContains(t, err, "no parser") } func TestConvert_NoSerializer(t *testing.T) { gpxData := []byte(``) - _, err := Convert("gpx_1.1", gpxData, []string{"columbus-csv"}, "f.gpx", false, MarkerOptions{}) + _, err := Convert("gpx_1.1", gpxData, []string{"columbus-csv"}, "f.gpx", false, MarkerOptions{}, nil) assert.ErrorContains(t, err, "no serializer") } @@ -210,7 +210,7 @@ func TestConvert_FilenameExtensionReplaced(t *testing.T) { gpxData := []byte(` `) - outs, err := Convert("gpx_1.1", gpxData, []string{"geojson"}, "my.track.gpx", false, MarkerOptions{}) + outs, err := Convert("gpx_1.1", gpxData, []string{"geojson"}, "my.track.gpx", false, MarkerOptions{}, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, "my.track.geojson", outs[0].Filename) @@ -230,7 +230,7 @@ func TestConvert_SplitColumbusCSVToMultipleGPXTracks(t *testing.T) { `) // Restrict accepted formats to GPX so the split is observable as elements. - outs, err := Convert("columbus-csv", csvData, []string{"gpx_1.1"}, "track.csv", false, MarkerOptions{Rules: []MarkerRule{{Marker: "C", Functionality: MarkerSplit}}, SplitMarkerPosition: "start"}) + outs, err := Convert("columbus-csv", csvData, []string{"gpx_1.1"}, "track.csv", false, MarkerOptions{Rules: []MarkerRule{{Marker: "C", Functionality: MarkerSplit}}, SplitMarkerPosition: "start"}, nil) require.NoError(t, err) require.Len(t, outs, 1, "tracks mode: one file") assert.Equal(t, "gpx_1.1", outs[0].Format) @@ -248,7 +248,7 @@ func TestConvert_SplitFilesMode_OneFilePerTrack(t *testing.T) { Rules: []MarkerRule{{Marker: "C", Functionality: MarkerSplit}}, SplitMarkerPosition: "start", SplitMode: "files", - }) + }, nil) require.NoError(t, err) require.Len(t, outs, 2, "files mode: one file per track") assert.Equal(t, "track-1.gpx", outs[0].Filename) @@ -264,7 +264,7 @@ func TestConvert_FilesMode_SingleTrackNoSuffix(t *testing.T) { 1,T,260417,110529,52.0N,13.0E,38,1.4,333 2,T,260417,110530,52.1N,13.1E,38,1.4,9 `) - outs, err := Convert("columbus-csv", csvData, []string{"gpx_1.1"}, "track.csv", false, MarkerOptions{SplitMode: "files"}) + outs, err := Convert("columbus-csv", csvData, []string{"gpx_1.1"}, "track.csv", false, MarkerOptions{SplitMode: "files"}, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, "track.gpx", outs[0].Filename) @@ -283,7 +283,7 @@ func TestConvert_FilesMode_DoesNotSplitSourceNativeTracks(t *testing.T) { Rules: []MarkerRule{{Marker: "C", Functionality: MarkerSplit}}, SplitMarkerPosition: "start", SplitMode: "files", - }) + }, nil) require.NoError(t, err) require.Len(t, outs, 1, "no split fired: a single file regardless of files mode") assert.Equal(t, "track.gpx", outs[0].Filename) @@ -296,7 +296,7 @@ func TestConvert_NoSplitColumbusCSVSingleGPXTrack(t *testing.T) { 2,C,260417,110530,52.1N,13.1E,38,30.0,9 `) - outs, err := Convert("columbus-csv", csvData, []string{"gpx_1.1"}, "track.csv", false, MarkerOptions{}) + outs, err := Convert("columbus-csv", csvData, []string{"gpx_1.1"}, "track.csv", false, MarkerOptions{}, nil) require.NoError(t, err) require.Len(t, outs, 1) assert.Equal(t, 1, strings.Count(string(outs[0].Data), ""), "no split tags configured: single track") diff --git a/server/internal/converter/geojson_serializer.go b/server/internal/converter/geojson_serializer.go index 0c6e44c..d85e6b9 100644 --- a/server/internal/converter/geojson_serializer.go +++ b/server/internal/converter/geojson_serializer.go @@ -43,6 +43,9 @@ func (s *geojsonSerializer) Serialize(tracks []Track) ([]byte, string, error) { props := map[string]interface{}{} + for k, v := range track.Properties { + props[k] = v + } if track.Name != "" { props["track_name"] = track.Name } diff --git a/server/internal/converter/track.go b/server/internal/converter/track.go index a0d7507..51634e9 100644 --- a/server/internal/converter/track.go +++ b/server/internal/converter/track.go @@ -5,7 +5,11 @@ import "time" // Track is the universal internal representation of a GPS track. // It is a superset of all attributes across supported formats. type Track struct { - Name string + Name string + + // Properties are extra per-track key/values emitted by formats that support them. + Properties map[string]string + Segments []Segment } diff --git a/server/internal/converter/transform_test.go b/server/internal/converter/transform_test.go new file mode 100644 index 0000000..85263e5 --- /dev/null +++ b/server/internal/converter/transform_test.go @@ -0,0 +1,61 @@ +package converter + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubTransformer struct { + key string + val string +} + +func (s stubTransformer) TransformTracks(format string, files [][]Track) (changed bool) { + for _, tracks := range files { + for i := range tracks { + if tracks[i].Properties == nil { + tracks[i].Properties = map[string]string{} + } + tracks[i].Properties[s.key] = s.val + changed = true + } + } + return changed +} + +func TestConvert_AppliesTransformer(t *testing.T) { + gpxData := []byte(``) + + outs, err := Convert("gpx_1.1", gpxData, []string{"geojson"}, "t.gpx", false, MarkerOptions{}, stubTransformer{key: "tracker_id", val: "abc"}) + require.NoError(t, err) + require.Len(t, outs, 1) + assert.Contains(t, string(outs[0].Data), `"tracker_id":"abc"`) +} + +func TestConvert_TransformerSkipsPassthrough(t *testing.T) { + gpxData := []byte(``) + + // Passthrough would return the original bytes, but a transformer that mutates + // the tracks must force re-serialization. + outs, err := Convert("gpx_1.1", gpxData, []string{"gpx_1.1", "geojson"}, "t.gpx", true, MarkerOptions{}, stubTransformer{key: "x", val: "y"}) + require.NoError(t, err) + require.Len(t, outs, 1) + assert.NotEqual(t, gpxData, outs[0].Data) +} + +func TestGeoJSONSerializer_EmitsProperties(t *testing.T) { + tracks := []Track{{ + Properties: map[string]string{"tracker_id": "abc"}, + Segments: []Segment{{Points: []Point{{Lat: 1, Lon: 2}}}}, + }} + + data, _, err := (&geojsonSerializer{}).Serialize(tracks) + require.NoError(t, err) + + var fc geojsonFeatureCollection + require.NoError(t, json.Unmarshal(data, &fc)) + assert.Equal(t, "abc", fc.Features[0].Properties["tracker_id"]) +} diff --git a/server/internal/server/server.go b/server/internal/server/server.go index 0aa4bd7..baeaa1d 100644 --- a/server/internal/server/server.go +++ b/server/internal/server/server.go @@ -152,8 +152,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { return } + // A target may also shape the tracks before serialization (e.g. tracker_id). + transformer, _ := t.(converter.TrackTransformer) + outputs, err := converter.Convert( - sourceFormat, data, t.AcceptedFormats(), header.Filename, s.cfg.PassthroughConversion, s.markerOpts[deviceID], + sourceFormat, data, t.AcceptedFormats(), header.Filename, s.cfg.PassthroughConversion, s.markerOpts[deviceID], transformer, ) if err != nil { slog.Error("conversion failed", diff --git a/server/internal/target/dawarich/dawarich.go b/server/internal/target/dawarich/dawarich.go index d9773d9..4251e56 100644 --- a/server/internal/target/dawarich/dawarich.go +++ b/server/internal/target/dawarich/dawarich.go @@ -14,8 +14,14 @@ import ( "github.com/Quadrubo/tracksync/server/internal/target" ) +// Config holds the dawarich-specific TARGET__DAWARICH__* settings. +type Config struct { + EmitTrackerID bool `env:"EMIT_TRACKER_ID"` +} + func init() { - target.Register("dawarich", func(cfg target.Config) (target.Target, error) { + target.RegisterConfig("dawarich", Config{}) + target.Register("dawarich", func(cfg target.Config, targetCfg any) (target.Target, error) { if cfg.URL == "" { return nil, fmt.Errorf("dawarich: URL is required") } @@ -23,16 +29,26 @@ func init() { if timeout == 0 { timeout = 30 * time.Second } + var dcfg Config + if targetCfg != nil { + c, ok := targetCfg.(Config) + if !ok { + return nil, fmt.Errorf("dawarich: invalid target config type %T", targetCfg) + } + dcfg = c + } return &Dawarich{ - cfg: cfg, - client: &http.Client{Timeout: timeout}, + cfg: cfg, + client: &http.Client{Timeout: timeout}, + emitTrackerID: dcfg.EmitTrackerID, }, nil }) } type Dawarich struct { - cfg target.Config - client *http.Client + cfg target.Config + client *http.Client + emitTrackerID bool } func (d *Dawarich) Type() string { return "dawarich" } diff --git a/server/internal/target/dawarich/dawarich_test.go b/server/internal/target/dawarich/dawarich_test.go index 3ffb83e..9f0377d 100644 --- a/server/internal/target/dawarich/dawarich_test.go +++ b/server/internal/target/dawarich/dawarich_test.go @@ -90,17 +90,34 @@ func TestReadAPIKey_File(t *testing.T) { } func TestDawarich_Timeout(t *testing.T) { - tgt, err := target.Get("dawarich", target.Config{URL: "http://localhost", APIKey: "k", Timeout: 42 * time.Second}) + tgt, err := target.Get("dawarich", target.Config{URL: "http://localhost", APIKey: "k", Timeout: 42 * time.Second}, nil) require.NoError(t, err) assert.Equal(t, 42*time.Second, tgt.(*Dawarich).client.Timeout) } func TestDawarich_DefaultTimeout(t *testing.T) { - tgt, err := target.Get("dawarich", target.Config{URL: "http://localhost", APIKey: "k"}) + tgt, err := target.Get("dawarich", target.Config{URL: "http://localhost", APIKey: "k"}, nil) require.NoError(t, err) assert.Equal(t, 30*time.Second, tgt.(*Dawarich).client.Timeout) } +func TestDawarich_EmitTrackerIDFromConfig(t *testing.T) { + tgt, err := target.Get("dawarich", target.Config{URL: "http://localhost", APIKey: "k"}, Config{EmitTrackerID: true}) + require.NoError(t, err) + assert.True(t, tgt.(*Dawarich).emitTrackerID) +} + +func TestDawarich_EmitTrackerIDDefaultsOff(t *testing.T) { + tgt, err := target.Get("dawarich", target.Config{URL: "http://localhost", APIKey: "k"}, nil) + require.NoError(t, err) + assert.False(t, tgt.(*Dawarich).emitTrackerID) +} + +func TestDawarich_WrongConfigType(t *testing.T) { + _, err := target.Get("dawarich", target.Config{URL: "http://localhost", APIKey: "k"}, "not-a-dawarich-config") + require.Error(t, err) +} + func TestDawarich_Type(t *testing.T) { assert.Equal(t, "dawarich", (&Dawarich{}).Type()) } diff --git a/server/internal/target/dawarich/tracker_id.go b/server/internal/target/dawarich/tracker_id.go new file mode 100644 index 0000000..214729e --- /dev/null +++ b/server/internal/target/dawarich/tracker_id.go @@ -0,0 +1,59 @@ +package dawarich + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + + "github.com/Quadrubo/tracksync/server/internal/converter" +) + +// TransformTracks tags each track with a stable tracker_id so Dawarich keeps +// split tracks apart instead of re-segmenting points by time gap. GeoJSON-only: +// GPX already imports each as its own track. +func (d *Dawarich) TransformTracks(format string, files [][]converter.Track) (changed bool) { + if !d.emitTrackerID || format != "geojson" { + return false + } + for _, tracks := range files { + for i := range tracks { + id := trackerID(tracks[i]) + if id == "" { + continue + } + if tracks[i].Properties == nil { + tracks[i].Properties = map[string]string{} + } + tracks[i].Properties["tracker_id"] = id + changed = true + } + } + return changed +} + +// trackerID derives a stable id from the track name and first point, unique per +// track and stable across re-uploads. Empty when the track has no points. +func trackerID(t converter.Track) string { + first, ok := firstPoint(t) + if !ok { + return "" + } + + key := t.Name + "|" + if first.Time != nil { + key += first.Time.UTC().Format("2006-01-02T15:04:05.000000000Z") + } + key += fmt.Sprintf("|%.7f|%.7f", first.Lat, first.Lon) + + sum := sha1.Sum([]byte(key)) + return "tracksync-" + hex.EncodeToString(sum[:])[:16] +} + +func firstPoint(t converter.Track) (converter.Point, bool) { + for _, seg := range t.Segments { + if len(seg.Points) > 0 { + return seg.Points[0], true + } + } + return converter.Point{}, false +} diff --git a/server/internal/target/dawarich/tracker_id_test.go b/server/internal/target/dawarich/tracker_id_test.go new file mode 100644 index 0000000..03a1746 --- /dev/null +++ b/server/internal/target/dawarich/tracker_id_test.go @@ -0,0 +1,55 @@ +package dawarich + +import ( + "strings" + "testing" + "time" + + "github.com/Quadrubo/tracksync/server/internal/converter" + "github.com/stretchr/testify/assert" +) + +func TestTransformTracks_TagsDistinctPerTrack(t *testing.T) { + t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 1, 1, 11, 0, 0, 0, time.UTC) + files := [][]converter.Track{{ + {Name: "Ride", Segments: []converter.Segment{{Points: []converter.Point{{Lat: 1, Lon: 2, Time: &t1}}}}}, + {Name: "Ride", Segments: []converter.Segment{{Points: []converter.Point{{Lat: 3, Lon: 4, Time: &t2}}}}}, + }} + + changed := (&Dawarich{emitTrackerID: true}).TransformTracks("geojson", files) + + assert.True(t, changed) + a := files[0][0].Properties["tracker_id"] + b := files[0][1].Properties["tracker_id"] + assert.True(t, strings.HasPrefix(a, "tracksync-")) + assert.NotEqual(t, a, b, "same name but different first point => distinct ids") +} + +func TestTransformTracks_DisabledIsNoop(t *testing.T) { + files := [][]converter.Track{{ + {Segments: []converter.Segment{{Points: []converter.Point{{Lat: 1, Lon: 2}}}}}, + }} + + changed := (&Dawarich{emitTrackerID: false}).TransformTracks("geojson", files) + assert.False(t, changed) + assert.Nil(t, files[0][0].Properties) +} + +func TestTransformTracks_SkipsTrackWithoutPoints(t *testing.T) { + files := [][]converter.Track{{{Name: "empty"}}} + + changed := (&Dawarich{emitTrackerID: true}).TransformTracks("geojson", files) + assert.False(t, changed) + assert.Nil(t, files[0][0].Properties) +} + +func TestTransformTracks_NonGeoJSONIsNoop(t *testing.T) { + files := [][]converter.Track{{ + {Name: "Ride", Segments: []converter.Segment{{Points: []converter.Point{{Lat: 1, Lon: 2}}}}}, + }} + + changed := (&Dawarich{emitTrackerID: true}).TransformTracks("gpx_1.1", files) + assert.False(t, changed) + assert.Nil(t, files[0][0].Properties) +} diff --git a/server/internal/target/target.go b/server/internal/target/target.go index 2d6c104..6df9e7d 100644 --- a/server/internal/target/target.go +++ b/server/internal/target/target.go @@ -17,10 +17,7 @@ type Target interface { Send(ctx context.Context, filename string, data []byte) error } -// Constructor creates a new Target from the given config. -type Constructor func(cfg Config) (Target, error) - -// Config holds the target-agnostic configuration. +// Config holds the connection settings shared by every target. type Config struct { URL string APIKey string @@ -28,8 +25,16 @@ type Config struct { Timeout time.Duration } +// Constructor creates a Target from the shared connection config and the +// target's own typed config (nil if it has none). +type Constructor func(cfg Config, targetCfg any) (Target, error) + var registry = map[string]Constructor{} +// configPrototypes holds a zero value of each target type's config struct, +// keyed by type name, used to parse its TARGET____* env vars. +var configPrototypes = map[string]any{} + // Register adds a target type to the global registry. func Register(typeName string, ctor Constructor) { if _, exists := registry[typeName]; exists { @@ -38,11 +43,31 @@ func Register(typeName string, ctor Constructor) { registry[typeName] = ctor } -// Get creates a new Target for the given type name and config. -func Get(typeName string, cfg Config) (Target, error) { +// RegisterConfig declares a target type's config via a zero value of its config +// struct (with `env`/`default` field tags). Targets without one skip this and +// their Constructor receives a nil targetCfg. +func RegisterConfig(typeName string, prototype any) { + if _, exists := configPrototypes[typeName]; exists { + panic(fmt.Sprintf("target config %q already registered", typeName)) + } + configPrototypes[typeName] = prototype +} + +// ConfigPrototypes returns a copy of the registered config prototypes, keyed by +// target type. +func ConfigPrototypes() map[string]any { + out := make(map[string]any, len(configPrototypes)) + for k, v := range configPrototypes { + out[k] = v + } + return out +} + +// Get creates a Target for the given type name and config. +func Get(typeName string, cfg Config, targetCfg any) (Target, error) { ctor, ok := registry[typeName] if !ok { return nil, fmt.Errorf("unknown target type %q", typeName) } - return ctor(cfg) + return ctor(cfg, targetCfg) } diff --git a/server/main.go b/server/main.go index 7b33646..162518b 100644 --- a/server/main.go +++ b/server/main.go @@ -37,7 +37,7 @@ func main() { APIKey: account.APIKey, APIKeyFile: account.APIKeyFile, Timeout: cfg.TargetTimeout, - }) + }, cfg.TargetConfigs[account.TargetType]) if err != nil { slog.Error("failed to create target", "device", account.DeviceID, "error", err) os.Exit(1)