Multi-select (Ctrl/Cmd+click toggle, Shift+click range, right-click the
active selection for bulk actions - same conventions as a native file
manager):
- Print Labels (N) / Export CSV (N) for any mix of individually-picked
assets and whole folders. Labels/export now take asset_ids/folder_ids
instead of a single asset_id/folder_id.
- Edit Status..., Check Out..., Duplicate, Move to..., and Delete, bulk
versions of the existing single-asset actions. Checkout/Move modals
were generalized to accept an array of ids instead of just one; status
editing got its own lightweight POST /app/assets/{id}/status endpoint
(assets.SetStatus) so a bulk status change doesn't need to resend every
other field the way a full PATCH would.
Labels:
- Choose QR (a /scan/<tag> URL, for a phone camera) or Code128 barcode
(the bare Asset ID, for a USB scanner typing into the search box) via a
format selector on the print page. New barcodeDataURI using
github.com/boombuler/barcode alongside the existing QR generator.
- "Start at #" lets printing resume partway into an already-used sheet
instead of wasting it on a fresh one.
- Added the Avery 5260 label template (same grid as 5160, rounded
corners, so it isn't flagged approximate).
Also: the configured Site Name now shows on the login page too, not just
the authenticated navbar.
239 lines
6.9 KiB
Go
239 lines
6.9 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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
|
|
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/<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]
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|