diff --git a/nixos/tracksync.nix b/nixos/tracksync.nix
index aefa512..a805a76 100644
--- a/nixos/tracksync.nix
+++ b/nixos/tracksync.nix
@@ -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')
+ 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')
@@ -79,9 +80,9 @@ let
fi
if [ "$RC" = 0 ]; then
- ${pkgs.libnotify}/bin/notify-send -i emblem-ok "Tracksync" "$UPLOADED uploaded, $SKIPPED skipped" 2>/dev/null || true
+ ${pkgs.libnotify}/bin/notify-send -i emblem-ok "Tracksync" "$UPLOADED uploaded, $DUPLICATE duplicate, $SKIPPED skipped" 2>/dev/null || true
else
- ${pkgs.libnotify}/bin/notify-send -i dialog-error "Tracksync" "$UPLOADED uploaded, $SKIPPED skipped, $ERRORS failed" 2>/dev/null || true
+ ${pkgs.libnotify}/bin/notify-send -i dialog-error "Tracksync" "$UPLOADED uploaded, $DUPLICATE duplicate, $SKIPPED skipped, $ERRORS failed" 2>/dev/null || true
exit 1
fi
'';
diff --git a/tracksync/internal/sync/sync.go b/tracksync/internal/sync/sync.go
index 9e7a6ed..a872738 100644
--- a/tracksync/internal/sync/sync.go
+++ b/tracksync/internal/sync/sync.go
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"fmt"
"io"
+ "log/slog"
"mime/multipart"
"net/http"
"os"
@@ -14,6 +15,7 @@ import (
"strings"
"time"
+ "github.com/Quadrubo/tracksync/tracksync/internal/device"
"github.com/Quadrubo/tracksync/tracksync/internal/migrations"
)
@@ -71,23 +73,31 @@ func ReadToken(path string) (string, error) {
return strings.TrimSpace(string(data)), nil
}
-func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFormat, filename string, data []byte) (string, error) {
+// UploadStatus represents the outcome of an upload attempt.
+type UploadStatus int
+
+const (
+ StatusUploaded UploadStatus = iota // server accepted as new
+ StatusDuplicate // server already had this file
+)
+
+func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFormat, filename string, data []byte) (UploadStatus, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
- return "", fmt.Errorf("creating form: %w", err)
+ return 0, fmt.Errorf("creating form: %w", err)
}
if _, err := part.Write(data); err != nil {
- return "", fmt.Errorf("writing form: %w", err)
+ return 0, fmt.Errorf("writing form: %w", err)
}
if err := writer.Close(); err != nil {
- return "", fmt.Errorf("closing form: %w", err)
+ return 0, fmt.Errorf("closing form: %w", err)
}
req, err := http.NewRequest("POST", serverURL+"/upload", &buf)
if err != nil {
- return "", fmt.Errorf("creating request: %w", err)
+ return 0, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
@@ -97,7 +107,7 @@ func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFor
resp, err := client.Do(req)
if err != nil {
- return "", fmt.Errorf("sending request: %w", err)
+ return 0, fmt.Errorf("sending request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
@@ -106,10 +116,74 @@ func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFor
switch resp.StatusCode {
case http.StatusCreated:
- return "uploaded", nil
+ return StatusUploaded, nil
case http.StatusOK:
- return "duplicate (server)", nil
+ return StatusDuplicate, nil
default:
- return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, status)
+ return 0, fmt.Errorf("server returned %d: %s", resp.StatusCode, status)
+ }
+}
+
+// Summary holds the results of a sync operation.
+type Summary struct {
+ Uploaded int `json:"uploaded"`
+ Duplicate int `json:"duplicate"`
+ Skipped int `json:"skipped"`
+ Errors int `json:"errors"`
+ Files []string `json:"files,omitempty"`
+}
+
+// SyncFiles syncs a list of found files to the server.
+func SyncFiles(db *sql.DB, client *http.Client, serverURL, token, deviceID, hostname string, files []device.FoundFile) Summary {
+ var summary Summary
+
+ for _, ff := range files {
+ name := filepath.Base(ff.Path)
+
+ data, err := os.ReadFile(ff.Path)
+ if err != nil {
+ slog.Error("failed to read file", "file", name, "error", err)
+ summary.Errors++
+ continue
+ }
+
+ hash := SHA256Hex(data)
+
+ uploaded, err := AlreadyUploaded(db, hash)
+ if err != nil {
+ slog.Error("failed to check upload state", "file", name, "error", err)
+ summary.Errors++
+ continue
+ }
+ if uploaded {
+ slog.Debug("skipped", "file", name, "reason", "already uploaded")
+ summary.Skipped++
+ continue
+ }
+
+ status, err := Upload(client, serverURL, token, deviceID, hostname, ff.Format, name, data)
+ if err != nil {
+ slog.Error("upload failed", "file", name, "error", err)
+ summary.Errors++
+ continue
+ }
+
+ if err := RecordUpload(db, hash, name, deviceID); err != nil {
+ slog.Error("failed to record upload", "file", name, "error", err)
+ summary.Errors++
+ continue
+ }
+
+ switch status {
+ case StatusUploaded:
+ slog.Info("uploaded", "file", name)
+ summary.Uploaded++
+ summary.Files = append(summary.Files, name)
+ case StatusDuplicate:
+ slog.Info("duplicate", "file", name, "reason", "already on server")
+ summary.Duplicate++
+ }
}
+
+ return summary
}
diff --git a/tracksync/internal/sync/sync_test.go b/tracksync/internal/sync/sync_test.go
index 483c650..b9f93c2 100644
--- a/tracksync/internal/sync/sync_test.go
+++ b/tracksync/internal/sync/sync_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/Quadrubo/tracksync/tracksync/internal/device"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
@@ -109,7 +110,7 @@ func TestUpload_Created(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
status, err := Upload(client, ts.URL, "tok", "dev-1", "myhost", "gpx_1.1", "track.gpx", []byte(""))
require.NoError(t, err)
- assert.Equal(t, "uploaded", status)
+ assert.Equal(t, StatusUploaded, status)
}
func TestUpload_Duplicate(t *testing.T) {
@@ -122,7 +123,7 @@ func TestUpload_Duplicate(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
status, err := Upload(client, ts.URL, "tok", "dev", "host", "gpx_1.1", "f.gpx", []byte("data"))
require.NoError(t, err)
- assert.Equal(t, "duplicate (server)", status)
+ assert.Equal(t, StatusDuplicate, status)
}
func TestUpload_ServerError(t *testing.T) {
@@ -137,6 +138,129 @@ func TestUpload_ServerError(t *testing.T) {
assert.Error(t, err)
}
+func TestSyncFiles_Uploaded(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ _, _ = fmt.Fprintln(w, "uploaded")
+ }))
+ defer ts.Close()
+
+ db := openTestDB(t)
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), []byte(""), 0644))
+
+ files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
+ summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)
+
+ assert.Equal(t, 1, summary.Uploaded)
+ assert.Equal(t, 0, summary.Duplicate)
+ assert.Equal(t, 0, summary.Skipped)
+ assert.Equal(t, 0, summary.Errors)
+ assert.Equal(t, []string{"track.gpx"}, summary.Files)
+}
+
+func TestSyncFiles_Duplicate(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = fmt.Fprintln(w, "duplicate")
+ }))
+ defer ts.Close()
+
+ db := openTestDB(t)
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), []byte(""), 0644))
+
+ files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
+ summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)
+
+ assert.Equal(t, 0, summary.Uploaded)
+ assert.Equal(t, 1, summary.Duplicate)
+ assert.Equal(t, 0, summary.Skipped)
+ assert.Equal(t, 0, summary.Errors)
+ assert.Nil(t, summary.Files)
+}
+
+func TestSyncFiles_SkippedClientSide(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("should not upload, file already in local DB")
+ }))
+ defer ts.Close()
+
+ db := openTestDB(t)
+ dir := t.TempDir()
+ data := []byte("")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), data, 0644))
+
+ // Pre-record the file in the local DB
+ require.NoError(t, RecordUpload(db, SHA256Hex(data), "track.gpx", "dev"))
+
+ files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
+ summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)
+
+ assert.Equal(t, 0, summary.Uploaded)
+ assert.Equal(t, 0, summary.Duplicate)
+ assert.Equal(t, 1, summary.Skipped)
+ assert.Equal(t, 0, summary.Errors)
+}
+
+func TestSyncFiles_UploadError(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = fmt.Fprintln(w, "error")
+ }))
+ defer ts.Close()
+
+ db := openTestDB(t)
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), []byte(""), 0644))
+
+ files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
+ summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)
+
+ assert.Equal(t, 0, summary.Uploaded)
+ assert.Equal(t, 0, summary.Duplicate)
+ assert.Equal(t, 0, summary.Skipped)
+ assert.Equal(t, 1, summary.Errors)
+}
+
+func TestSyncFiles_MixedResults(t *testing.T) {
+ callCount := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ callCount++
+ switch callCount {
+ case 1:
+ w.WriteHeader(http.StatusCreated)
+ _, _ = fmt.Fprintln(w, "uploaded")
+ case 2:
+ w.WriteHeader(http.StatusOK)
+ _, _ = fmt.Fprintln(w, "duplicate")
+ }
+ }))
+ defer ts.Close()
+
+ db := openTestDB(t)
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "new.gpx"), []byte("new-data"), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "dup.gpx"), []byte("dup-data"), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "skip.gpx"), []byte("skip-data"), 0644))
+
+ // Pre-record skip.gpx
+ require.NoError(t, RecordUpload(db, SHA256Hex([]byte("skip-data")), "skip.gpx", "dev"))
+
+ files := []device.FoundFile{
+ {Path: filepath.Join(dir, "new.gpx"), Format: "gpx_1.1"},
+ {Path: filepath.Join(dir, "dup.gpx"), Format: "gpx_1.1"},
+ {Path: filepath.Join(dir, "skip.gpx"), Format: "gpx_1.1"},
+ }
+ summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)
+
+ assert.Equal(t, 1, summary.Uploaded)
+ assert.Equal(t, 1, summary.Duplicate)
+ assert.Equal(t, 1, summary.Skipped)
+ assert.Equal(t, 0, summary.Errors)
+ assert.Equal(t, []string{"new.gpx"}, summary.Files)
+}
+
func TestOpenStateDB_CreatesDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "dir", "state.db")
db, err := OpenStateDB(path)
diff --git a/tracksync/main.go b/tracksync/main.go
index 3a114f7..67ef0c8 100644
--- a/tracksync/main.go
+++ b/tracksync/main.go
@@ -15,13 +15,6 @@ import (
_ "modernc.org/sqlite"
)
-type Summary struct {
- Uploaded int `json:"uploaded"`
- Skipped int `json:"skipped"`
- Errors int `json:"errors"`
- Files []string `json:"files,omitempty"`
-}
-
func main() {
serverURL := flag.String("server-url", "", "sync server URL (required)")
tokenFlag := flag.String("token", "", "auth token (inline)")
@@ -123,11 +116,9 @@ func main() {
os.Exit(1)
}
- summary := Summary{}
-
if len(files) == 0 {
slog.Info("no files found", "device", *deviceID)
- writeSummary(summary)
+ writeSummary(sync.Summary{})
return
}
@@ -139,49 +130,11 @@ func main() {
"files", len(files),
)
- for _, ff := range files {
- name := filepath.Base(ff.Path)
-
- data, err := os.ReadFile(ff.Path)
- if err != nil {
- slog.Error("failed to read file", "file", name, "error", err)
- summary.Errors++
- continue
- }
-
- hash := sync.SHA256Hex(data)
-
- uploaded, err := sync.AlreadyUploaded(db, hash)
- if err != nil {
- slog.Error("failed to check upload state", "file", name, "error", err)
- summary.Errors++
- continue
- }
- if uploaded {
- slog.Debug("skipped", "file", name, "reason", "already uploaded")
- summary.Skipped++
- continue
- }
-
- status, err := sync.Upload(httpClient, *serverURL, token, *deviceID, hostname, ff.Format, name, data)
- if err != nil {
- slog.Error("upload failed", "file", name, "error", err)
- summary.Errors++
- continue
- }
-
- if err := sync.RecordUpload(db, hash, name, *deviceID); err != nil {
- slog.Error("failed to record upload", "file", name, "error", err)
- summary.Errors++
- continue
- }
- slog.Info("uploaded", "file", name, "status", status)
- summary.Uploaded++
- summary.Files = append(summary.Files, name)
- }
+ summary := sync.SyncFiles(db, httpClient, *serverURL, token, *deviceID, hostname, files)
slog.Info("sync complete",
"uploaded", summary.Uploaded,
+ "duplicate", summary.Duplicate,
"skipped", summary.Skipped,
"errors", summary.Errors,
)
@@ -194,7 +147,7 @@ func main() {
}
// writeSummary outputs the summary as JSON to stdout for machine consumption.
-func writeSummary(s Summary) {
+func writeSummary(s sync.Summary) {
_ = json.NewEncoder(os.Stdout).Encode(s)
}