feat: field-sync-gui MVP — Wails GUI ingest SD→local+S3 (4 slots //)
This commit is contained in:
42
internal/config/config.go
Normal file
42
internal/config/config.go
Normal 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
163
internal/copier/copier.go
Normal 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
75
internal/detect/detect.go
Normal 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]
|
||||
}
|
||||
25
internal/detect/detect_test.go
Normal file
25
internal/detect/detect_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
internal/manifest/manifest.go
Normal file
67
internal/manifest/manifest.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user