add label generation api (#498)

* add label generation api

* show location name on labels

* add label scan page

* dispose of code reader when navigating away from scan page

* save label to png

* implement code suggestions

* fix label padding and margin

* update swagger docs

* add print from browser dialog

Co-authored-by: fidoriel <49869342+fidoriel@users.noreply.github.com>

* increase label description font weight

* update documentation label file suffix

* fix scanner components import

* fix linting issues

---------

Co-authored-by: fidoriel <49869342+fidoriel@users.noreply.github.com>
This commit is contained in:
Jake Walker
2025-02-09 02:26:16 +00:00
committed by GitHub
parent 401fd7fc71
commit fba6d7817a
24 changed files with 1233 additions and 50 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"github.com/olahol/melody"
)
@@ -72,6 +73,7 @@ type V1Controller struct {
allowRegistration bool
bus *eventbus.EventBus
url string
config *config.Config
}
type (
@@ -92,15 +94,17 @@ type (
Latest services.Latest `json:"latest"`
Demo bool `json:"demo"`
AllowRegistration bool `json:"allowRegistration"`
LabelPrinting bool `json:"labelPrinting"`
}
)
func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *eventbus.EventBus, options ...func(*V1Controller)) *V1Controller {
func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *eventbus.EventBus, config *config.Config, options ...func(*V1Controller)) *V1Controller {
ctrl := &V1Controller{
repo: repos,
svc: svc,
allowRegistration: true,
bus: bus,
config: config,
}
for _, opt := range options {
@@ -127,6 +131,7 @@ func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.Hand
Latest: ctrl.svc.BackgroundService.GetLatestVersion(),
Demo: ctrl.isDemo,
AllowRegistration: ctrl.allowRegistration,
LabelPrinting: ctrl.config.LabelMaker.PrintCommand != nil,
})
}
}

View File

@@ -0,0 +1,30 @@
package v1
import (
"net/url"
"github.com/rs/zerolog/log"
)
func GetHBURL(refererHeader, fallback string) (hbURL string) {
hbURL = refererHeader
if hbURL == "" {
hbURL = fallback
}
return stripPathFromURL(hbURL)
}
// stripPathFromURL removes the path from a URL.
// ex. https://example.com/tools -> https://example.com
func stripPathFromURL(rawURL string) string {
parsedURL, err := url.Parse(rawURL)
if err != nil {
log.Err(err).Msg("failed to parse URL")
return ""
}
strippedURL := url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}
return strippedURL.String()
}

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"time"
@@ -339,7 +338,7 @@ func (ctrl *V1Controller) HandleItemsExport() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
csvData, err := ctrl.svc.Items.ExportCSV(r.Context(), ctx.GID, getHBURL(r.Header.Get("Referer"), ctrl.url))
csvData, err := ctrl.svc.Items.ExportCSV(r.Context(), ctx.GID, GetHBURL(r.Header.Get("Referer"), ctrl.url))
if err != nil {
log.Err(err).Msg("failed to export items")
return validate.NewRequestError(err, http.StatusInternalServerError)
@@ -356,26 +355,3 @@ func (ctrl *V1Controller) HandleItemsExport() errchain.HandlerFunc {
return writer.WriteAll(csvData)
}
}
func getHBURL(refererHeader, fallback string) (hbURL string) {
hbURL = refererHeader
if hbURL == "" {
hbURL = fallback
}
return stripPathFromURL(hbURL)
}
// stripPathFromURL removes the path from a URL.
// ex. https://example.com/tools -> https://example.com
func stripPathFromURL(rawURL string) string {
parsedURL, err := url.Parse(rawURL)
if err != nil {
log.Err(err).Msg("failed to parse URL")
return ""
}
strippedURL := url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}
return strippedURL.String()
}

View File

@@ -0,0 +1,136 @@
package v1
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/hay-kot/httpkit/errchain"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
"github.com/sysadminsmedia/homebox/backend/pkgs/labelmaker"
)
func generateOrPrint(ctrl *V1Controller, w http.ResponseWriter, r *http.Request, title string, description string, url string) error {
params := labelmaker.NewGenerateParams(int(ctrl.config.LabelMaker.Width), int(ctrl.config.LabelMaker.Height), int(ctrl.config.LabelMaker.Margin), int(ctrl.config.LabelMaker.Padding), ctrl.config.LabelMaker.FontSize, title, description, url)
print := queryBool(r.URL.Query().Get("print"))
if print {
err := labelmaker.PrintLabel(ctrl.config, &params)
if err != nil {
return err
}
_, err = w.Write([]byte("Printed!"))
return err
} else {
return labelmaker.GenerateLabel(w, &params)
}
}
// HandleGetLocationLabel godoc
//
// @Summary Get Location label
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/location/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGetLocationLabel() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ID, err := adapters.RouteUUID(r, "id")
if err != nil {
return err
}
auth := services.NewContext(r.Context())
location, err := ctrl.repo.Locations.GetOneByGroup(auth, auth.GID, ID)
if err != nil {
return err
}
hbURL := GetHBURL(r.Header.Get("Referer"), ctrl.url)
return generateOrPrint(ctrl, w, r, location.Name, "Homebox Location", fmt.Sprintf("%s/location/%s", hbURL, location.ID))
}
}
// HandleGetItemLabel godoc
//
// @Summary Get Item label
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/item/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGetItemLabel() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ID, err := adapters.RouteUUID(r, "id")
if err != nil {
return err
}
auth := services.NewContext(r.Context())
item, err := ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
if err != nil {
return err
}
description := ""
if item.Location != nil {
description += fmt.Sprintf("\nLocation: %s", item.Location.Name)
}
hbURL := GetHBURL(r.Header.Get("Referer"), ctrl.url)
return generateOrPrint(ctrl, w, r, item.Name, description, fmt.Sprintf("%s/item/%s", hbURL, item.ID))
}
}
// HandleGetAssetLabel godoc
//
// @Summary Get Asset label
// @Tags Items
// @Produce json
// @Param id path string true "Asset ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/assets/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGetAssetLabel() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
assetIDParam := chi.URLParam(r, "id")
assetIDParam = strings.ReplaceAll(assetIDParam, "-", "")
assetID, err := strconv.ParseInt(assetIDParam, 10, 64)
if err != nil {
return err
}
auth := services.NewContext(r.Context())
item, err := ctrl.repo.Items.QueryByAssetID(auth, auth.GID, repo.AssetID(assetID), 0, 1)
if err != nil {
return err
}
if len(item.Items) == 0 {
return validate.NewRequestError(fmt.Errorf("failed to find asset id"), http.StatusNotFound)
}
description := item.Items[0].Name
if item.Items[0].Location != nil {
description += fmt.Sprintf("\nLocation: %s", item.Items[0].Location.Name)
}
hbURL := GetHBURL(r.Header.Get("Referer"), ctrl.url)
return generateOrPrint(ctrl, w, r, item.Items[0].AssetID.String(), description, fmt.Sprintf("%s/a/%s", hbURL, item.Items[0].AssetID.String()))
}
}

View File

@@ -52,6 +52,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
a.services,
a.repos,
a.bus,
a.conf,
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
v1.WithRegistration(a.conf.Options.AllowRegistration),
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
@@ -161,6 +162,11 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentGet(), assetMW...),
)
// Labelmaker
r.Get("/labelmaker/location/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGetLocationLabel(), userMW...))
r.Get("/labelmaker/item/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGetItemLabel(), userMW...))
r.Get("/labelmaker/asset/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGetAssetLabel(), userMW...))
// Reporting Services
r.Get("/reporting/bill-of-materials", chain.ToHandlerFunc(v1Ctrl.HandleBillOfMaterialsExport(), userMW...))

View File

@@ -1039,6 +1039,123 @@ const docTemplate = `{
}
}
},
"/v1/labelmaker/assets/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Items"
],
"summary": "Get Asset label",
"parameters": [
{
"type": "string",
"description": "Asset ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Print this label, defaults to false",
"name": "print",
"in": "query"
}
],
"responses": {
"200": {
"description": "image/png",
"schema": {
"type": "string"
}
}
}
}
},
"/v1/labelmaker/item/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Items"
],
"summary": "Get Item label",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Print this label, defaults to false",
"name": "print",
"in": "query"
}
],
"responses": {
"200": {
"description": "image/png",
"schema": {
"type": "string"
}
}
}
}
},
"/v1/labelmaker/location/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Locations"
],
"summary": "Get Location label",
"parameters": [
{
"type": "string",
"description": "Location ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Print this label, defaults to false",
"name": "print",
"in": "query"
}
],
"responses": {
"200": {
"description": "image/png",
"schema": {
"type": "string"
}
}
}
}
},
"/v1/labels": {
"get": {
"security": [
@@ -2993,6 +3110,9 @@ const docTemplate = `{
"health": {
"type": "boolean"
},
"labelPrinting": {
"type": "boolean"
},
"latest": {
"$ref": "#/definitions/services.Latest"
},

View File

@@ -1032,6 +1032,123 @@
}
}
},
"/v1/labelmaker/assets/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Items"
],
"summary": "Get Asset label",
"parameters": [
{
"type": "string",
"description": "Asset ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Print this label, defaults to false",
"name": "print",
"in": "query"
}
],
"responses": {
"200": {
"description": "image/png",
"schema": {
"type": "string"
}
}
}
}
},
"/v1/labelmaker/item/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Items"
],
"summary": "Get Item label",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Print this label, defaults to false",
"name": "print",
"in": "query"
}
],
"responses": {
"200": {
"description": "image/png",
"schema": {
"type": "string"
}
}
}
}
},
"/v1/labelmaker/location/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Locations"
],
"summary": "Get Location label",
"parameters": [
{
"type": "string",
"description": "Location ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Print this label, defaults to false",
"name": "print",
"in": "query"
}
],
"responses": {
"200": {
"description": "image/png",
"schema": {
"type": "string"
}
}
}
}
},
"/v1/labels": {
"get": {
"security": [
@@ -2986,6 +3103,9 @@
"health": {
"type": "boolean"
},
"labelPrinting": {
"type": "boolean"
},
"latest": {
"$ref": "#/definitions/services.Latest"
},

View File

@@ -682,6 +682,8 @@ definitions:
type: boolean
health:
type: boolean
labelPrinting:
type: boolean
latest:
$ref: '#/definitions/services.Latest'
message:
@@ -1406,6 +1408,78 @@ paths:
summary: Import Items
tags:
- Items
/v1/labelmaker/assets/{id}:
get:
parameters:
- description: Asset ID
in: path
name: id
required: true
type: string
- description: Print this label, defaults to false
in: query
name: print
type: boolean
produces:
- application/json
responses:
"200":
description: image/png
schema:
type: string
security:
- Bearer: []
summary: Get Asset label
tags:
- Items
/v1/labelmaker/item/{id}:
get:
parameters:
- description: Item ID
in: path
name: id
required: true
type: string
- description: Print this label, defaults to false
in: query
name: print
type: boolean
produces:
- application/json
responses:
"200":
description: image/png
schema:
type: string
security:
- Bearer: []
summary: Get Item label
tags:
- Items
/v1/labelmaker/location/{id}:
get:
parameters:
- description: Location ID
in: path
name: id
required: true
type: string
- description: Print this label, defaults to false
in: query
name: print
type: boolean
produces:
- application/json
responses:
"200":
description: image/png
schema:
type: string
security:
- Bearer: []
summary: Get Location label
tags:
- Locations
/v1/labels:
get:
produces: