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) }