Files
vibetree/internal/web/handlers_labels.go
T
Owen e86ffe765c Add Avery 5260 label template
Same 30-per-sheet, 1" x 2-5/8" grid as 5160 (rounded corners are the
only difference), so unlike 61526 this one isn't flagged approximate.
2026-08-17 16:50:17 -05:00

167 lines
5.0 KiB
Go

package web
import (
"fmt"
"html/template"
"net/http"
"inventree/internal/assets"
"inventree/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
// 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,
},
"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,
},
"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, 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 {
width: %.4fin;
height: %.4fin;
box-sizing: border-box;
padding: 0.06in 0.08in;
display: flex;
align-items: center;
gap: 0.08in;
overflow: hidden;
page-break-inside: avoid;
}
.label img { width: %.4fin; height: %.4fin; flex-shrink: 0; }
`,
t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn,
t.LabelWidthIn, t.LabelHeightIn, t.QRSizeIn, t.QRSizeIn))
}
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.
QRData template.URL
}
type labelsPageData struct {
User *models.User
Labels []labelItem
SheetCSS template.CSS
Templates map[string]LabelTemplate
TemplateKeys []string
Selected LabelTemplate
AssetID string
FolderID string
Recursive bool
}
// handleLabelsPage renders a print-ready sheet of QR-coded asset labels:
// either a single asset (?asset_id=) or every asset in a folder
// (?folder_id=&recursive=1), or the whole inventory with neither param.
// Each QR code encodes an absolute /scan/<tag> 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]
}
var list []models.Asset
assetIDParam := r.URL.Query().Get("asset_id")
folderIDParam := r.URL.Query().Get("folder_id")
recursive := r.URL.Query().Get("recursive") == "1"
if assetID, err := queryOptionalID(r, "asset_id"); err == nil && assetID != nil {
asset, err := a.Assets.Get(ctx, *assetID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
list = []models.Asset{*asset}
} else {
filter := assets.ListFilter{Page: 1, PageSize: 2000}
if folderID, err := queryOptionalID(r, "folder_id"); err == nil && folderID != nil {
filter.FolderID = folderID
filter.FolderSet = true
filter.IncludeSub = recursive
}
batch, _, err := a.Assets.List(ctx, filter)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
list = batch
}
labels := make([]labelItem, 0, len(list))
for _, as := range list {
qr, err := qrDataURI(origin+"/scan/"+as.AssetTag, 220)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
labels = append(labels, labelItem{Asset: as, QRData: template.URL(qr)})
}
a.Render(w, http.StatusOK, "labels", labelsPageData{
User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(),
Templates: labelTemplates, TemplateKeys: []string{"5160", "5260", "61526"},
Selected: tmpl, AssetID: assetIDParam, FolderID: folderIDParam, Recursive: recursive,
})
}