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

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