tmain.go: add HTTP endpoint and project metadata parsing - ags-upload - Insert AGS files to a database
 (HTM) git clone git://src.adamsgaard.dk/ags-upload
 (DIR) Log
 (DIR) Files
 (DIR) Refs
       ---
 (DIR) commit 12b840e26e1e7437fea2415dc2794eb9a1c932cd
 (DIR) parent 4550d9d7168992906b8f7ccc7b0a4ab286f44a8f
 (HTM) Author: Anders Damsgaard <anders@adamsgaard.dk>
       Date:   Wed,  8 Oct 2025 11:03:58 +0200
       
       main.go: add HTTP endpoint and project metadata parsing
       
       Diffstat:
         M cmd/main.go                         |     173 +++++++++++++++++++++++++++++--
       
       1 file changed, 166 insertions(+), 7 deletions(-)
       ---
 (DIR) diff --git a/cmd/main.go b/cmd/main.go
       t@@ -1,9 +1,15 @@
        package main
        
        import (
       +        "bytes"
       +        "encoding/csv"
                "fmt"
       +        "io"
                "log"
       +        "net/http"
                "os"
       +        "strings"
       +        "time"
        
                "github.com/gin-gonic/gin"
                "gorm.io/driver/postgres"
       t@@ -11,9 +17,99 @@ import (
                "gorm.io/gorm/schema"
        )
        
       -type Cpt struct {
       -        ID     uint `gorm:"primaryKey"`
       -        LocaId string
       +type CptInfo struct {
       +        ID         uint   `gorm:"primaryKey"`
       +        SourceId   string // PROJ_ID
       +        Name       string // PROJ_NAME
       +        Location   string // PROJ_LOC
       +        Client     string // PROJ_CLNT
       +        Contractor string // PROJ_CONT
       +}
       +
       +func ParseAGS(r io.Reader) (*CptInfo, error) {
       +
       +        norm, err := dos2unix(r)
       +        if err != nil {
       +                return nil, fmt.Errorf("read: %w", err)
       +        }
       +
       +        cr := csv.NewReader(norm)
       +        cr.FieldsPerRecord = -1
       +        cr.LazyQuotes = true
       +
       +        var (
       +                inPROJ      bool
       +                headerIndex map[string]int
       +        )
       +
       +        for {
       +                rec, err := cr.Read()
       +                if err == io.EOF {
       +                        break
       +                }
       +                if err != nil {
       +                        return nil, fmt.Errorf("csv: %w", err)
       +                }
       +                if len(rec) == 0 {
       +                        continue
       +                }
       +
       +                for i := range rec {
       +                        rec[i] = strings.TrimSpace(rec[i])
       +                }
       +
       +                switch strings.ToUpper(rec[0]) {
       +                case "GROUP":
       +                        inPROJ = len(rec) > 1 && strings.EqualFold(rec[1], "PROJ")
       +                        headerIndex = nil
       +                case "HEADING":
       +                        if !inPROJ {
       +                                continue
       +                        }
       +                        headerIndex = make(map[string]int)
       +                        for i := 1; i < len(rec); i++ {
       +                                key := strings.ToUpper(strings.TrimSpace(rec[i]))
       +                                headerIndex[key] = i - 1 // positions in the "DATA" slice after skipping the first token
       +                        }
       +                case "DATA":
       +                        if !inPROJ || headerIndex == nil {
       +                                continue
       +                        }
       +                        data := rec[1:] // align with headerIndex positions
       +
       +                        get := func(h string) string {
       +                                if idx, ok := headerIndex[strings.ToUpper(h)]; ok && idx >= 0 && idx < len(data) {
       +                                        return data[idx]
       +                                }
       +                                return ""
       +                        }
       +
       +                        p := &CptInfo{
       +                                SourceId:   get("PROJ_ID"),
       +                                Name:       get("PROJ_NAME"),
       +                                Location:   get("PROJ_LOC"),
       +                                Client:     get("PROJ_CLNT"),
       +                                Contractor: get("PROJ_CONT"),
       +                        }
       +                        return p, nil
       +
       +                default:
       +                        continue
       +                }
       +        }
       +
       +        return nil, fmt.Errorf("no data found")
       +}
       +
       +func dos2unix(r io.Reader) (io.Reader, error) {
       +        all, err := io.ReadAll(r)
       +        if err != nil {
       +                return nil, err
       +        }
       +        all = bytes.ReplaceAll(all, []byte("\r\n"), []byte("\n"))
       +        all = bytes.ReplaceAll(all, []byte("\r"), []byte("\n"))
       +
       +        return bytes.NewReader(all), nil
        }
        
        func main() {
       t@@ -35,16 +131,79 @@ func main() {
                        log.Fatal(err)
                }
        
       -        if err := db.AutoMigrate(&Cpt{}); err != nil {
       +        if err := db.AutoMigrate(&CptInfo{}); err != nil {
                        log.Fatal(err)
                }
        
       -        db.Create(&Cpt{LocaId: "asdf"})
       -
                r := gin.Default()
       -        r.POST("/ingest/:ags", func(c *gin.Context) {
       +
       +        // ~32 MB file cap for multipart
       +        r.MaxMultipartMemory = 32 << 20
       +
       +        r.POST("/ingest/ags", func(c *gin.Context) {
       +                reader, cleanup, err := getAGSReader(c.Request)
       +                if err != nil {
       +                        c.String(http.StatusBadRequest, "upload error: %v", err)
       +                        return
       +                }
       +                if cleanup != nil {
       +                        defer cleanup()
       +                }
       +
       +                p, err := ParseAGS(reader)
       +                if err != nil {
       +                        c.String(http.StatusBadRequest, "parse error: %v", err)
       +                        return
       +                }
       +
       +                err = db.
       +                        Where("source_id = ?", p.SourceId).
       +                        Assign(p).
       +                        FirstOrCreate(p).Error
       +                if err != nil {
       +                        c.String(http.StatusInternalServerError, "db error: %v", err)
       +                        return
       +                }
       +
       +                c.JSON(http.StatusCreated, gin.H{
       +                        "id":         p.ID,
       +                        "sourceId":   p.SourceId,
       +                        "name":       p.Name,
       +                        "location":   p.Location,
       +                        "client":     p.Client,
       +                        "contractor": p.Contractor,
       +                        "savedAt":    time.Now().Format(time.RFC3339),
       +                })
                })
        
                _ = r.Run(":8080")
       +}
        
       +func getAGSReader(req *http.Request) (io.Reader, func(), error) {
       +        ct := req.Header.Get("Content-Type")
       +
       +        // Multipart form upload
       +        if strings.HasPrefix(ct, "multipart/form-data") {
       +                file, _, err := req.FormFile("file")
       +                if err != nil {
       +                        return nil, nil, err
       +                }
       +                return file, func() { _ = file.Close() }, nil
       +        }
       +
       +        // Raw body upload
       +        switch {
       +        case strings.HasPrefix(ct, "text/plain"),
       +                strings.HasPrefix(ct, "text/csv"),
       +                strings.HasPrefix(ct, "application/octet-stream"),
       +                ct == "":
       +                bodyBytes, err := io.ReadAll(req.Body)
       +                if err != nil {
       +                        return nil, nil, err
       +                }
       +                _ = req.Body.Close()
       +                return strings.NewReader(string(bodyBytes)), nil, nil
       +        default:
       +                return nil, nil, http.ErrNotSupported
       +        }
        }