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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,9 @@ docker-compose.local.yml
.env
result

# IDEs
.idea/
.vscode/

# NixOS
.direnv
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ All configuration is done via environment variables.
| `ACCOUNT__N__TARGET_URL` | | Yes | Target instance URL |
| `ACCOUNT__N__API_KEY` | | Yes if not API_KEY_FILE | API key (inline) |
| `ACCOUNT__N__API_KEY_FILE` | | Yes if not API_KEY | API key (file path) |
| `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) |
| `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 @@ -191,6 +194,47 @@ For example, when a Columbus P-10 Pro is configured to output CSV (which include
| `columbus-csv` | Parse | Yes | Yes | Yes | No | No |
| `geojson` | Serialize | Yes | Yes | Yes | Yes | Yes |

## Marker functionalities

Points can carry a *marker*, a source-format annotation such as a manually
placed POI or waypoint. You can assign a functionality to each marker so that
tracksync acts on it. This works at the universal-track level and is
format-agnostic: every parser maps its format's native markers onto a point marker, and functionalities operate on those.

Configure rules with `ACCOUNT__N__MARKERS` as comma-separated
`marker:functionality` pairs:

```
ACCOUNT__0__MARKERS=C:split
# multiple markers, each with its own functionality:
ACCOUNT__0__MARKERS=C:split,D:split
```

Which marker values are available depends on the source format:

| Format | Markers |
| -------------- | ---------------------------------------------------------------------------------------- |
| `columbus-csv` | `TAG` column values other than `T`: `C` (function-key POI), `D` (second POI), `G` (automatic wake-up point, usually leave unmapped) |

### Available functionalities

| Functionality | Effect |
| ------------- | ---------------------------------------------------------------------------------------- |
| `split` | Start a new track at the marked point. Useful for separating legs of a journey. For example pressing a logger's function key when boarding and leaving a bus so the walking and bus legs become distinct tracks. |

For the `split` functionality, `ACCOUNT__N__SPLIT_MARKER_POSITION` controls which
side of the split the marked point lands on: `start` (default) makes it the first
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`, …).

## Supported targets

| Type | Service | Accepted formats |
Expand Down
8 changes: 6 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ run-server:
run-server-docker:
cd server && docker compose up --build

clear-server-data:
rm -f server/data/state.db server/data/state.db-shm server/data/state.db-wal

# Maintenance
update-vendor-hash:
bash scripts/update-vendor-hash.sh

clear-local-data:
rm ~/.local/share/tracksync/state.db

dangerously-clear-system-client-data:
rm -f ~/.local/share/tracksync/state.db ~/.local/share/tracksync/state.db-shm ~/.local/share/tracksync/state.db-wal
5 changes: 3 additions & 2 deletions nixos/tracksync.nix
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ let
${lib.optionalString (cfg.stateDB != null) "--state-db \"${cfg.stateDB}\""}) && RC=0 || RC=$?

UPLOADED=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.uploaded // 0')
FORWARDED=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.forwarded // 0')
DUPLICATE=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.duplicate // 0')
SKIPPED=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.skipped // 0')
ERRORS=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.errors // 0')
Expand All @@ -80,9 +81,9 @@ let
fi

if [ "$RC" = 0 ]; then
${pkgs.libnotify}/bin/notify-send -i emblem-ok "Tracksync" "$UPLOADED uploaded, $DUPLICATE duplicate, $SKIPPED skipped" 2>/dev/null || true
${pkgs.libnotify}/bin/notify-send -i emblem-ok "Tracksync" "$UPLOADED uploaded ($FORWARDED forwarded), $DUPLICATE duplicate, $SKIPPED skipped" 2>/dev/null || true
else
${pkgs.libnotify}/bin/notify-send -i dialog-error "Tracksync" "$UPLOADED uploaded, $DUPLICATE duplicate, $SKIPPED skipped, $ERRORS failed" 2>/dev/null || true
${pkgs.libnotify}/bin/notify-send -i dialog-error "Tracksync" "$UPLOADED uploaded ($FORWARDED forwarded), $DUPLICATE duplicate, $SKIPPED skipped, $ERRORS failed" 2>/dev/null || true
exit 1
fi
'';
Expand Down
4 changes: 4 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ ACCOUNT__0__DEVICE_ID=my-columbus
ACCOUNT__0__TARGET_URL=http://localhost:3000
ACCOUNT__0__API_KEY=your-dawarich-api-key

ACCOUNT__0__MARKERS=C:split
# ACCOUNT__0__SPLIT_MARKER_POSITION=start
# ACCOUNT__0__SPLIT_MODE=tracks

CLIENT__0__ID=my-laptop
CLIENT__0__TOKEN=your-client-token
CLIENT__0__ALLOWED_DEVICES=my-columbus
26 changes: 20 additions & 6 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

"github.com/Quadrubo/tracksync/server/internal/converter"
"github.com/go-playground/validator/v10"
"github.com/spf13/viper"
)
Expand All @@ -21,8 +22,18 @@ func validateConfig(sl validator.StructLevel) {
cfg := sl.Current().Interface().(Config)

accountDevices := make(map[string]bool)
for _, a := range cfg.Accounts {
for i, a := range cfg.Accounts {
accountDevices[a.DeviceID] = true

if _, err := converter.ParseMarkerRules(a.Markers); err != nil {
sl.ReportError(
a.Markers,
fmt.Sprintf("Accounts[%d].Markers", i),
"Markers",
"valid_marker_rules",
err.Error(),
)
}
}

for i, c := range cfg.Clients {
Expand Down Expand Up @@ -51,11 +62,14 @@ type Config struct {
}

type Account struct {
DeviceID string `env:"DEVICE_ID" validate:"required"`
TargetType string `env:"TARGET_TYPE" default:"dawarich" validate:"required"`
TargetURL string `env:"TARGET_URL" validate:"required"`
APIKey string `env:"API_KEY" validate:"required_without=APIKeyFile"`
APIKeyFile string `env:"API_KEY_FILE" validate:"required_without=APIKey"`
DeviceID string `env:"DEVICE_ID" validate:"required"`
TargetType string `env:"TARGET_TYPE" default:"dawarich" validate:"required"`
TargetURL string `env:"TARGET_URL" validate:"required"`
APIKey string `env:"API_KEY" validate:"required_without=APIKeyFile"`
APIKeyFile string `env:"API_KEY_FILE" validate:"required_without=APIKey"`
Markers []string `env:"MARKERS"`
SplitMarkerPosition string `env:"SPLIT_MARKER_POSITION" default:"start" validate:"omitempty,oneof=start end"`
SplitMode string `env:"SPLIT_MODE" default:"tracks" validate:"omitempty,oneof=tracks files"`
}

type Client struct {
Expand Down
85 changes: 85 additions & 0 deletions server/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,91 @@ func TestParseGroup_DefaultTargetType(t *testing.T) {
assert.Equal(t, "dawarich", accounts[0].TargetType)
}

func TestParseGroup_SplitConfig(t *testing.T) {
v := viper.New()
v.Set("ACCOUNT__0__DEVICE_ID", "dev-1")
v.Set("ACCOUNT__0__TARGET_URL", "http://localhost:3000")
v.Set("ACCOUNT__0__API_KEY", "key")
v.Set("ACCOUNT__0__MARKERS", "C:split, D:split")
v.Set("ACCOUNT__0__SPLIT_MARKER_POSITION", "end")

accounts := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID")

require.Len(t, accounts, 1)
assert.Equal(t, []string{"C:split", "D:split"}, accounts[0].Markers)
assert.Equal(t, "end", accounts[0].SplitMarkerPosition)
}

func TestParseGroup_SplitDefaults(t *testing.T) {
v := viper.New()
v.Set("ACCOUNT__0__DEVICE_ID", "dev-1")
v.Set("ACCOUNT__0__TARGET_URL", "http://localhost:3000")
v.Set("ACCOUNT__0__API_KEY", "key")

accounts := parseGroup[Account](v, "ACCOUNT", "DEVICE_ID")

require.Len(t, accounts, 1)
assert.Nil(t, accounts[0].Markers, "no markers by default")
assert.Equal(t, "start", accounts[0].SplitMarkerPosition)
assert.Equal(t, "tracks", accounts[0].SplitMode)
}

func TestValidate_SplitMarkerPositionInvalid(t *testing.T) {
cfg := &Config{
Accounts: []Account{{DeviceID: "d", TargetType: "dawarich", TargetURL: "http://x", APIKey: "k", SplitMarkerPosition: "middle"}},
Clients: []Client{{ID: "c", Token: "t"}},
}
assert.Error(t, cfg.validate(), "marker position must be start or end")
}

func TestValidate_SplitMarkerPositionValid(t *testing.T) {
cfg := &Config{
Accounts: []Account{{DeviceID: "d", TargetType: "dawarich", TargetURL: "http://x", APIKey: "k", SplitMarkerPosition: "end"}},
Clients: []Client{{ID: "c", Token: "t"}},
}
assert.NoError(t, cfg.validate())
}

func TestValidate_MarkersValid(t *testing.T) {
cfg := &Config{
Accounts: []Account{{DeviceID: "d", TargetType: "dawarich", TargetURL: "http://x", APIKey: "k", Markers: []string{"C:split", "D:split"}}},
Clients: []Client{{ID: "c", Token: "t"}},
}
assert.NoError(t, cfg.validate())
}

func TestValidate_MarkersUnknownFunctionality(t *testing.T) {
cfg := &Config{
Accounts: []Account{{DeviceID: "d", TargetType: "dawarich", TargetURL: "http://x", APIKey: "k", Markers: []string{"C:bogus"}}},
Clients: []Client{{ID: "c", Token: "t"}},
}
assert.Error(t, cfg.validate())
}

func TestValidate_MarkersMalformed(t *testing.T) {
cfg := &Config{
Accounts: []Account{{DeviceID: "d", TargetType: "dawarich", TargetURL: "http://x", APIKey: "k", Markers: []string{"C"}}},
Clients: []Client{{ID: "c", Token: "t"}},
}
assert.Error(t, cfg.validate())
}

func TestValidate_SplitModeInvalid(t *testing.T) {
cfg := &Config{
Accounts: []Account{{DeviceID: "d", TargetType: "dawarich", TargetURL: "http://x", APIKey: "k", SplitMode: "zip"}},
Clients: []Client{{ID: "c", Token: "t"}},
}
assert.Error(t, cfg.validate(), "split mode must be tracks or files")
}

func TestValidate_SplitModeValid(t *testing.T) {
cfg := &Config{
Accounts: []Account{{DeviceID: "d", TargetType: "dawarich", TargetURL: "http://x", APIKey: "k", SplitMode: "files"}},
Clients: []Client{{ID: "c", Token: "t"}},
}
assert.NoError(t, cfg.validate())
}

func TestParseGroup_Clients(t *testing.T) {
v := viper.New()
v.Set("CLIENT__0__ID", "laptop")
Expand Down
68 changes: 46 additions & 22 deletions server/internal/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,66 @@ import (
"strings"
)

// Convert parses data in sourceFormat, selects the best target format from
// acceptedFormats, and serializes the tracks. Returns converted data, chosen
// format, and the new filename.
// OutputFile is a single converted file produced by Convert.
type OutputFile struct {
Data []byte
Format string
Filename string
}

// 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, data is always
// re-serialized to produce normalized output.
func Convert(sourceFormat string, data []byte, acceptedFormats []string, originalFilename string, passthrough bool) ([]byte, string, string, error) {
// 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) {
parser, ok := GetParser(sourceFormat)
if !ok {
return nil, "", "", fmt.Errorf("no parser for format %q", sourceFormat)
return nil, fmt.Errorf("no parser for format %q", sourceFormat)
}

tracks, err := parser.Parse(data)
if err != nil {
return nil, "", "", fmt.Errorf("parsing %s: %w", sourceFormat, err)
return nil, fmt.Errorf("parsing %s: %w", sourceFormat, err)
}

result := applyMarkers(tracks, markers)

// Determine which fields the parsed tracks actually contain.
usedFields := mergeUsedFields(tracks)
usedFields := mergeUsedFields(result.Tracks())

bestFormat := selectBestFormat(usedFields, acceptedFormats)
if bestFormat == "" {
return nil, "", "", fmt.Errorf("no serializer available for any accepted format: %v", acceptedFormats)
return nil, fmt.Errorf("no serializer available for any accepted format: %v", acceptedFormats)
}

// Passthrough: if explicitly enabled and the best format matches the source,
// return original data without re-serializing.
if passthrough && bestFormat == sourceFormat {
return data, bestFormat, originalFilename, nil
// 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
}

serializer, ok := GetSerializer(bestFormat)
if !ok {
return nil, "", "", fmt.Errorf("no serializer for format %q", bestFormat)
return nil, fmt.Errorf("no serializer for format %q", bestFormat)
}

out, ext, err := serializer.Serialize(tracks)
if err != nil {
return nil, "", "", fmt.Errorf("serializing to %s: %w", bestFormat, err)
// Suffix filenames only when split across multiple files.
multiFile := len(result.Files) > 1
out := make([]OutputFile, 0, len(result.Files))
for i, fileTracks := range result.Files {
fileData, ext, err := serializer.Serialize(fileTracks)
if err != nil {
return nil, fmt.Errorf("serializing to %s: %w", bestFormat, err)
}
filename := replaceExtension(originalFilename, ext)
if multiFile {
filename = indexedFilename(filename, i+1)
}
out = append(out, OutputFile{Data: fileData, Format: bestFormat, Filename: filename})
}

newFilename := replaceExtension(originalFilename, ext)
return out, bestFormat, newFilename, nil
return out, nil
}

// mergeUsedFields combines detected fields across all tracks.
Expand All @@ -69,3 +85,11 @@ func replaceExtension(filename, newExt string) string {
}
return filename + newExt
}

// indexedFilename inserts "-n" before the extension, e.g. track.geojson -> track-1.geojson.
func indexedFilename(filename string, n int) string {
if idx := strings.LastIndex(filename, "."); idx >= 0 {
return fmt.Sprintf("%s-%d%s", filename[:idx], n, filename[idx:])
}
return fmt.Sprintf("%s-%d", filename, n)
}
Loading
Loading