ags-upload

Insert AGS files to a database
git clone git://src.adamsgaard.dk/ags-upload # fast
git clone https://src.adamsgaard.dk/ags-upload.git # slow
Log | Files | Refs Back to index

commit 12b840e26e1e7437fea2415dc2794eb9a1c932cd
parent 4550d9d7168992906b8f7ccc7b0a4ab286f44a8f
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:
Mcmd/main.go | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 166 insertions(+), 7 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -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" @@ -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() { @@ -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 + } }