feat: field-sync-gui MVP — Wails GUI ingest SD→local+S3 (4 slots //)

This commit is contained in:
2026-04-27 22:10:15 +00:00
commit dcaf5ac020
46 changed files with 5135 additions and 0 deletions

42
internal/config/config.go Normal file
View File

@@ -0,0 +1,42 @@
package config
import (
"os"
"strconv"
"github.com/joho/godotenv"
)
// Config holds runtime configuration
type Config struct {
S3Bucket string `json:"s3Bucket"`
LocalDest string `json:"localDest"`
AWSProfile string `json:"awsProfile"`
Concurrency int `json:"concurrency"`
}
// Load reads config from ENV (with optional .env file)
func Load(envFile string) Config {
_ = godotenv.Load(envFile) // ignore if missing
concurrency := 4
if v := os.Getenv("FIELD_SYNC_CONCURRENCY"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
concurrency = n
}
}
return Config{
S3Bucket: getenv("FIELD_SYNC_S3_BUCKET", ""),
LocalDest: getenv("FIELD_SYNC_LOCAL_DEST", os.TempDir()),
AWSProfile: getenv("AWS_PROFILE", "cosma"),
Concurrency: concurrency,
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

163
internal/copier/copier.go Normal file
View File

@@ -0,0 +1,163 @@
package copier
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const maxRetries = 3
// FileJob represents one file to copy
type FileJob struct {
Source string
LocalDest string
S3Bucket string
S3Key string
AWSProfile string
}
// FileResult is the result of copying one file
type FileResult struct {
Job FileJob
SHA256 string
Bytes int64
Error error
}
// ProgressFn is called with (bytesCopied, totalBytes) during local copy
type ProgressFn func(n int64, total int64)
// CopyLocal copies src to dst, returns sha256 and bytes written
func CopyLocal(src, dst string, progress ProgressFn) (string, int64, error) {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return "", 0, err
}
in, err := os.Open(src)
if err != nil {
return "", 0, err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return "", 0, err
}
total := info.Size()
out, err := os.Create(dst)
if err != nil {
return "", 0, err
}
defer out.Close()
h := sha256.New()
var written int64
buf := make([]byte, 1<<20) // 1MB buffer
for {
nr, er := in.Read(buf)
if nr > 0 {
nw, ew := out.Write(buf[:nr])
if nw > 0 {
written += int64(nw)
h.Write(buf[:nw])
if progress != nil {
progress(written, total)
}
}
if ew != nil {
return "", written, ew
}
}
if er == io.EOF {
break
}
if er != nil {
return "", written, er
}
}
return hex.EncodeToString(h.Sum(nil)), written, nil
}
// CopyS3 uploads localPath to s3://bucket/key via s5cmd
func CopyS3(ctx context.Context, localPath, bucket, key, awsProfile string) error {
s3uri := fmt.Sprintf("s3://%s/%s", bucket, key)
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<attempt) * time.Second
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
args := []string{"cp", "--concurrency", "8", localPath, s3uri}
cmd := exec.CommandContext(ctx, "s5cmd", args...)
if awsProfile != "" {
cmd.Env = append(os.Environ(), "AWS_PROFILE="+awsProfile)
}
out, err := cmd.CombinedOutput()
if err == nil {
return nil
}
if strings.Contains(string(out), "NoSuchBucket") {
return fmt.Errorf("bucket not found: %s", bucket)
}
}
return fmt.Errorf("s5cmd failed after %d retries", maxRetries)
}
// Pool runs file copy jobs with worker concurrency
type Pool struct {
Workers int
OnResult func(FileResult)
}
// Run processes jobs from the jobs channel until closed or ctx is done
func (p *Pool) Run(ctx context.Context, jobs <-chan FileJob) {
var wg sync.WaitGroup
for i := 0; i < p.Workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
select {
case <-ctx.Done():
return
default:
}
result := p.processJob(ctx, job)
if p.OnResult != nil {
p.OnResult(result)
}
}
}()
}
wg.Wait()
}
func (p *Pool) processJob(ctx context.Context, job FileJob) FileResult {
sha, bytes, err := CopyLocal(job.Source, job.LocalDest, nil)
if err != nil {
return FileResult{Job: job, Error: fmt.Errorf("local copy: %w", err)}
}
if job.S3Bucket != "" {
if err := CopyS3(ctx, job.LocalDest, job.S3Bucket, job.S3Key, job.AWSProfile); err != nil {
return FileResult{Job: job, SHA256: sha, Bytes: bytes, Error: fmt.Errorf("s3: %w", err)}
}
}
return FileResult{Job: job, SHA256: sha, Bytes: bytes}
}

75
internal/detect/detect.go Normal file
View File

@@ -0,0 +1,75 @@
package detect
import (
"path/filepath"
"strings"
"github.com/shirou/gopsutil/v3/disk"
)
// SDCard represents a detected removable storage device
type SDCard struct {
Mountpoint string `json:"mountpoint"`
Label string `json:"label"`
Device string `json:"device"`
Fstype string `json:"fstype"`
TotalBytes uint64 `json:"totalBytes"`
FreeBytes uint64 `json:"freeBytes"`
MediaFiles []string `json:"mediaFiles"`
}
// mediaExtensions is the set of file extensions we care about
var mediaExtensions = map[string]bool{
".mp4": true, ".mov": true, ".jpg": true, ".jpeg": true,
".png": true, ".raw": true, ".gpr": true, ".360": true,
".lrv": true, ".thm": true,
}
// Detect returns all removable partitions that look like SD cards / media
func Detect() []SDCard {
partitions, err := disk.Partitions(true)
if err != nil {
return nil
}
var cards []SDCard
for _, p := range partitions {
if !isRemovable(p) {
continue
}
usage, err := disk.Usage(p.Mountpoint)
if err != nil {
continue
}
card := SDCard{
Mountpoint: p.Mountpoint,
Label: filepath.Base(p.Mountpoint),
Device: p.Device,
Fstype: p.Fstype,
TotalBytes: usage.Total,
FreeBytes: usage.Free,
}
cards = append(cards, card)
}
return cards
}
// isRemovable heuristic: opts contain "removable" or path looks like /media/
func isRemovable(p disk.PartitionStat) bool {
opts := strings.Join(p.Opts, ",")
if strings.Contains(opts, "removable") {
return true
}
mp := p.Mountpoint
if strings.HasPrefix(mp, "/media/") || strings.HasPrefix(mp, "/run/media/") ||
strings.HasPrefix(mp, "/Volumes/") || strings.HasPrefix(mp, "/mnt/sd") {
return true
}
return false
}
// HasMediaExtension returns true if path has a known media extension
func HasMediaExtension(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return mediaExtensions[ext]
}

View File

@@ -0,0 +1,25 @@
package detect
import "testing"
func TestDetectNoPanic(t *testing.T) {
cards := Detect()
// May be empty in CI — just verify no panic
_ = cards
}
func TestHasMediaExtension(t *testing.T) {
cases := map[string]bool{
"clip.mp4": true,
"photo.JPG": true,
"photo.raw": true,
"file.txt": false,
"archive.zip": false,
}
for path, want := range cases {
got := HasMediaExtension(path)
if got != want {
t.Errorf("HasMediaExtension(%q) = %v, want %v", path, got, want)
}
}
}

View File

@@ -0,0 +1,67 @@
package manifest
import (
"encoding/json"
"os"
"sync"
)
// Entry is one copied file record
type Entry struct {
Source string `json:"source"`
LocalDest string `json:"localDest"`
S3Path string `json:"s3Path,omitempty"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
Status string `json:"status"` // "local" | "s3" | "done" | "error"
Error string `json:"error,omitempty"`
}
// Manifest holds all entries for a session
type Manifest struct {
SessionID string `json:"sessionID"`
Entries []Entry `json:"entries"`
mu sync.Mutex
}
// New creates a new manifest
func New(sessionID string) *Manifest {
return &Manifest{SessionID: sessionID}
}
// Append adds an entry atomically
func (m *Manifest) Append(e Entry) {
m.mu.Lock()
defer m.mu.Unlock()
m.Entries = append(m.Entries, e)
}
// Save writes manifest atomically via temp file + rename
func (m *Manifest) Save(path string) error {
m.mu.Lock()
defer m.mu.Unlock()
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// Load reads a manifest from disk
func Load(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}