From e70dce275c09934bf506d44e8614569df49e14c4 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 31 May 2026 14:51:18 -0700 Subject: [PATCH] add mobile putaway web server with camera/upload capture --- index.html | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 32 +++++++++---- server.go | 69 +++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 index.html create mode 100644 server.go diff --git a/index.html b/index.html new file mode 100644 index 0000000..a75dee4 --- /dev/null +++ b/index.html @@ -0,0 +1,136 @@ + + + + + + + +Spool Putaway + + + +
+
+
+ +
+
+ + + + diff --git a/main.go b/main.go index 4714f57..85f23ae 100644 --- a/main.go +++ b/main.go @@ -52,15 +52,12 @@ const spoolURLPrefix = "https://spooldb.com/s/" func main() { verbose := flag.Bool("v", false, "verbose: log each event's start/end with timings to stderr") dryRun := flag.Bool("n", false, "dry run: read and report, but do not write weight changes back to the site") + serveAddr := flag.String("serve", "", "run the mobile putaway web server at this address (e.g. :8080)") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "usage: %s [-v] [-n] ...\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "usage: %s [-v] [-n] [-serve addr] ...\n", os.Args[0]) flag.PrintDefaults() } flag.Parse() - if flag.NArg() < 1 { - flag.Usage() - os.Exit(2) - } level := slog.LevelInfo if *verbose { @@ -77,6 +74,17 @@ func main() { sp := &spoolSync{} defer sp.close() + if *serveAddr != "" { + if err := serve(*serveAddr, auth, sp, *dryRun); err != nil { + fail("serve: %v", err) + } + return + } + + if flag.NArg() < 1 { + flag.Usage() + os.Exit(2) + } results := make([]result, 0, flag.NArg()) for _, path := range flag.Args() { results = append(results, processImage(path, auth, sp, *dryRun)) @@ -163,14 +171,18 @@ func (s *spoolSync) close() { // processImage reads one photo, capturing any failure in the result's Error // field so a single bad image doesn't abort the whole batch. func processImage(path string, auth claude.Auth, sp *spoolSync, dryRun bool) result { - defer trace("image " + path)() - r := result{Image: path} - img, err := loadImage(path) if err != nil { - r.Error = fmt.Sprintf("load image: %v", err) - return r + return result{Image: path, Error: fmt.Sprintf("load image: %v", err)} } + return processImg(img, path, auth, sp, dryRun) +} + +// processImg runs the QR + weight + spooldb logic on a decoded image. name is +// just a label for the output (a file path or upload filename). +func processImg(img image.Image, name string, auth claude.Auth, sp *spoolSync, dryRun bool) result { + defer trace("image " + name)() + r := result{Image: name} url, err := decodeQR(img) if err != nil { diff --git a/server.go b/server.go new file mode 100644 index 0000000..f110057 --- /dev/null +++ b/server.go @@ -0,0 +1,69 @@ +package main + +import ( + _ "embed" + "encoding/json" + "image" + "log/slog" + "net/http" + "sync" + "time" + + "spoolweight/claude" +) + +//go:embed index.html +var indexHTML []byte + +// serve runs the mobile putaway web server. Requests are serialized because the +// shared spooldb browser session is single-threaded. +func serve(addr string, auth claude.Auth, sp *spoolSync, dryRun bool) error { + var mu sync.Mutex + + mux := http.NewServeMux() + + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) + }) + + mux.HandleFunc("POST /process", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if err := r.ParseMultipartForm(64 << 20); err != nil { + writeJSON(w, result{Error: "upload too large or malformed"}) + return + } + file, hdr, err := r.FormFile("photo") + if err != nil { + writeJSON(w, result{Error: "no photo in request"}) + return + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + writeJSON(w, result{Image: hdr.Filename, Error: "could not read image (try choosing from your library)"}) + return + } + + start := time.Now() + mu.Lock() + res := processImg(img, hdr.Filename, auth, sp, dryRun) + mu.Unlock() + slog.Info("processed upload", "file", hdr.Filename, "spool", res.SpoolID, "dur", time.Since(start)) + + writeJSON(w, res) + }) + + slog.Info("putaway server listening", "addr", addr, "dry_run", dryRun) + return http.ListenAndServe(addr, mux) +} + +func writeJSON(w http.ResponseWriter, v any) { + json.NewEncoder(w).Encode(v) +}