Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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

Expand Down
135 changes: 102 additions & 33 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()

Expand All @@ -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__<TYPE>__* 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.
Expand Down
46 changes: 40 additions & 6 deletions server/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}

Expand Down Expand Up @@ -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")
}
14 changes: 12 additions & 2 deletions server/internal/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@ 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.
//
// 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)
Expand All @@ -32,14 +38,18 @@ 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)
if bestFormat == "" {
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
Expand Down
Loading
Loading