package web import ( "fmt" "html/template" "net/http" "strconv" "strings" "vibetree/internal/assets" "vibetree/internal/models" ) // LabelTemplate describes a sheet of adhesive labels: cell size, grid, and // page margins, all in inches, used to lay out printable asset tags. type LabelTemplate struct { Key string DisplayName string LabelWidthIn float64 LabelHeightIn float64 Cols int Rows int MarginTopIn float64 MarginLeftIn float64 ColGapIn float64 RowGapIn float64 QRSizeIn float64 BarcodeWidthIn float64 BarcodeHeightIn float64 // Approximate is true for templates whose margins/grid aren't from a // verified manufacturer spec sheet - print one test page on plain // paper and hold it up to a real label sheet before committing a // full (often expensive/waterproof) sheet to the printer. Approximate bool } var labelTemplates = map[string]LabelTemplate{ "5160": { Key: "5160", DisplayName: `Avery 5160 - 1" x 2-5/8" (30/sheet)`, LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10, MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0, QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4, }, "5260": { // Same 30-per-sheet grid as 5160 (1" x 2-5/8"); 5260 is just the // rounded-corner version of the same physical layout. Key: "5260", DisplayName: `Avery 5260 - 1" x 2-5/8" (30/sheet, rounded corners)`, LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10, MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0, QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4, }, "61526": { Key: "61526", DisplayName: `Asset Tag Labels 61526 - 3/4" x 2" (~48/sheet)`, LabelWidthIn: 2, LabelHeightIn: 0.75, Cols: 4, Rows: 12, MarginTopIn: 1, MarginLeftIn: 0.25, ColGapIn: 0, RowGapIn: 0, QRSizeIn: 0.62, BarcodeWidthIn: 1.7, BarcodeHeightIn: 0.3, Approximate: true, }, } const defaultLabelTemplate = "5160" func (t LabelTemplate) css() template.CSS { return template.CSS(fmt.Sprintf(` .label-sheet { width: 8.5in; box-sizing: border-box; padding: %.4fin %.4fin; margin: 0 auto; display: grid; grid-template-columns: repeat(%d, %.4fin); column-gap: %.4fin; row-gap: %.4fin; justify-content: center; } .label, .label-blank { width: %.4fin; height: %.4fin; box-sizing: border-box; } .label { padding: 0.06in 0.08in; overflow: hidden; page-break-inside: avoid; } .label.format-qr { display: flex; align-items: center; gap: 0.08in; } .label.format-qr img { width: %.4fin; height: %.4fin; flex-shrink: 0; } .label.format-barcode { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.03in; } .label.format-barcode img { width: %.4fin; height: %.4fin; } `, t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn, t.LabelWidthIn, t.LabelHeightIn, t.QRSizeIn, t.QRSizeIn, t.BarcodeWidthIn, t.BarcodeHeightIn)) } type labelItem struct { Asset models.Asset // template.URL marks this as pre-vetted safe (it's server-generated, // never user input), so html/template's URL filter doesn't replace it // with the "#ZgotmplZ" safety placeholder the way it would a plain // string containing a data: URI. ImageData template.URL } type labelsPageData struct { User *models.User Labels []labelItem SheetCSS template.CSS Templates map[string]LabelTemplate TemplateKeys []string Selected LabelTemplate Format string // "qr" or "barcode" AssetIDs string FolderIDs string StartPos int BlankCells []int // len == StartPos-1, just for {{range}} to emit that many placeholder cells } func parseIDList(s string) []int64 { var out []int64 for _, part := range strings.Split(s, ",") { part = strings.TrimSpace(part) if part == "" { continue } if id, err := strconv.ParseInt(part, 10, 64); err == nil { out = append(out, id) } } return out } // handleLabelsPage renders a print-ready sheet of QR-coded asset labels for // any combination of individual assets (?asset_ids=1,2,3) and whole folders // (?folder_ids=5,9, expanded recursively), or the entire inventory when // neither is given. ?start=N (1-indexed) leaves the first N-1 cells blank, // so a partially-used sheet can be resumed instead of wasted. Each QR code // encodes an absolute /scan/ URL so a phone camera (or a USB barcode // scanner focused on a browser address bar) opens straight to that asset. func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() origin := requestOrigin(r) tmpl, ok := labelTemplates[r.URL.Query().Get("template")] if !ok { tmpl = labelTemplates[defaultLabelTemplate] } startPos := queryInt(r, "start", 1) if startPos < 1 { startPos = 1 } assetIDsParam := r.URL.Query().Get("asset_ids") folderIDsParam := r.URL.Query().Get("folder_ids") seen := map[int64]bool{} var list []models.Asset addAsset := func(as *models.Asset) { if !seen[as.ID] { seen[as.ID] = true list = append(list, *as) } } if assetIDsParam == "" && folderIDsParam == "" { batch, _, err := a.Assets.List(ctx, assets.ListFilter{Page: 1, PageSize: 2000}) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } for i := range batch { addAsset(&batch[i]) } } else { for _, id := range parseIDList(assetIDsParam) { if as, err := a.Assets.Get(ctx, id); err == nil { addAsset(as) } } for _, fid := range parseIDList(folderIDsParam) { batch, _, err := a.Assets.List(ctx, assets.ListFilter{ FolderID: &fid, FolderSet: true, IncludeSub: true, Page: 1, PageSize: 2000, }) if err != nil { continue } for i := range batch { addAsset(&batch[i]) } } } format := r.URL.Query().Get("format") if format != "barcode" { format = "qr" } labels := make([]labelItem, 0, len(list)) for _, as := range list { var ( data string err error ) if format == "barcode" { // A linear barcode is meant for a dedicated scanner typing the // raw tag into whatever's focused (e.g. the app's search box), // so it encodes the bare Asset ID rather than a URL. data, err = barcodeDataURI(as.AssetTag, int(tmpl.BarcodeWidthIn*300), int(tmpl.BarcodeHeightIn*300)) } else { // A QR code is meant for a phone camera, so it encodes a full // URL that opens straight to the asset. data, err = qrDataURI(origin+"/scan/"+as.AssetTag, 220) } if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } labels = append(labels, labelItem{Asset: as, ImageData: template.URL(data)}) } a.Render(w, http.StatusOK, "labels", labelsPageData{ User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(), Templates: labelTemplates, TemplateKeys: []string{"5160", "5260", "61526"}, Selected: tmpl, Format: format, AssetIDs: assetIDsParam, FolderIDs: folderIDsParam, StartPos: startPos, BlankCells: make([]int, startPos-1), }) }