mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 14:31:55 +01:00
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
30
backend/app/api/handlers/v1/helpers.go
Normal file
30
backend/app/api/handlers/v1/helpers.go
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
136
backend/app/api/handlers/v1/v1_ctrl_labelmaker.go
Normal file
136
backend/app/api/handlers/v1/v1_ctrl_labelmaker.go
Normal 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, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte("Printed!"))
|
||||
return err
|
||||
} else {
|
||||
return labelmaker.GenerateLabel(w, ¶ms)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()))
|
||||
}
|
||||
}
|
||||
@@ -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...))
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -43,7 +43,7 @@ require (
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
@@ -57,11 +57,12 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/zclconf/go-cty v1.16.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
|
||||
golang.org/x/image v0.23.0 // indirect
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
|
||||
@@ -117,6 +117,8 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
||||
|
||||
@@ -400,11 +400,11 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
|
||||
|
||||
if q.OnlyWithPhoto {
|
||||
andPredicates = append(andPredicates, item.HasAttachmentsWith(
|
||||
attachment.And(
|
||||
attachment.Primary(true),
|
||||
attachment.TypeEQ(attachment.TypePhoto),
|
||||
),
|
||||
attachment.And(
|
||||
attachment.Primary(true),
|
||||
attachment.TypeEQ(attachment.TypePhoto),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,15 @@ const (
|
||||
|
||||
type Config struct {
|
||||
conf.Version
|
||||
Mode string `yaml:"mode" conf:"default:development"` // development or production
|
||||
Web WebConfig `yaml:"web"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
Log LoggerConf `yaml:"logger"`
|
||||
Mailer MailerConf `yaml:"mailer"`
|
||||
Demo bool `yaml:"demo"`
|
||||
Debug DebugConf `yaml:"debug"`
|
||||
Options Options `yaml:"options"`
|
||||
Mode string `yaml:"mode" conf:"default:development"` // development or production
|
||||
Web WebConfig `yaml:"web"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
Log LoggerConf `yaml:"logger"`
|
||||
Mailer MailerConf `yaml:"mailer"`
|
||||
Demo bool `yaml:"demo"`
|
||||
Debug DebugConf `yaml:"debug"`
|
||||
Options Options `yaml:"options"`
|
||||
LabelMaker LabelMakerConf `yaml:"labelmaker"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -49,6 +50,15 @@ type WebConfig struct {
|
||||
IdleTimeout time.Duration `yaml:"idle_timeout" conf:"default:30s"`
|
||||
}
|
||||
|
||||
type LabelMakerConf struct {
|
||||
Width int64 `yaml:"width" conf:"default:526"`
|
||||
Height int64 `yaml:"height" conf:"default:200"`
|
||||
Padding int64 `yaml:"padding" conf:"default:32"`
|
||||
Margin int64 `yaml:"margin" conf:"default:32"`
|
||||
FontSize float64 `yaml:"font_size" conf:"default:32.0"`
|
||||
PrintCommand *string `yaml:"string"`
|
||||
}
|
||||
|
||||
// New parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the
|
||||
// file is not read. If the file is not empty, the file is read and the Config struct is returned.
|
||||
func New(buildstr string, description string) (*Config, error) {
|
||||
|
||||
271
backend/pkgs/labelmaker/labelmaker.go
Normal file
271
backend/pkgs/labelmaker/labelmaker.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// Package labelmaker provides functionality for generating and printing labels for items, locations and assets stored in Homebox
|
||||
package labelmaker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/gofont/gobold"
|
||||
"golang.org/x/image/font/gofont/gomedium"
|
||||
)
|
||||
|
||||
type GenerateParameters struct {
|
||||
Width int
|
||||
Height int
|
||||
QrSize int
|
||||
Margin int
|
||||
ComponentPadding int
|
||||
TitleText string
|
||||
TitleFontSize float64
|
||||
DescriptionText string
|
||||
DescriptionFontSize float64
|
||||
Dpi float64
|
||||
URL string
|
||||
}
|
||||
|
||||
func (p *GenerateParameters) Validate() error {
|
||||
if p.Width <= 0 {
|
||||
return fmt.Errorf("invalid width")
|
||||
}
|
||||
if p.Height <= 0 {
|
||||
return fmt.Errorf("invalid height")
|
||||
}
|
||||
if p.Margin < 0 {
|
||||
return fmt.Errorf("invalid margin")
|
||||
}
|
||||
if p.ComponentPadding < 0 {
|
||||
return fmt.Errorf("invalid component padding")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewGenerateParams(width int, height int, margin int, padding int, fontSize float64, title string, description string, url string) GenerateParameters {
|
||||
return GenerateParameters{
|
||||
Width: width,
|
||||
Height: height,
|
||||
QrSize: height - (padding * 2),
|
||||
Margin: margin,
|
||||
ComponentPadding: padding,
|
||||
TitleText: title,
|
||||
DescriptionText: description,
|
||||
TitleFontSize: fontSize,
|
||||
DescriptionFontSize: fontSize * 0.8,
|
||||
Dpi: 72,
|
||||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
func measureString(text string, face font.Face, ctx *freetype.Context) int {
|
||||
width := 0
|
||||
for _, r := range text {
|
||||
awidth, _ := face.GlyphAdvance(r)
|
||||
width += awidth.Round()
|
||||
}
|
||||
return ctx.PointToFixed(float64(width)).Round()
|
||||
}
|
||||
|
||||
// wrapText breaks text into lines that fit within maxWidth
|
||||
func wrapText(text string, face font.Face, maxWidth int, ctx *freetype.Context) []string {
|
||||
lines := strings.Split(text, "\n")
|
||||
var wrappedLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
words := strings.Fields(line)
|
||||
if len(words) == 0 {
|
||||
wrappedLines = append(wrappedLines, "")
|
||||
continue
|
||||
}
|
||||
|
||||
currentLine := words[0]
|
||||
for _, word := range words[1:] {
|
||||
testLine := currentLine + " " + word
|
||||
width := measureString(testLine, face, ctx)
|
||||
|
||||
if width <= maxWidth {
|
||||
currentLine = testLine
|
||||
} else {
|
||||
wrappedLines = append(wrappedLines, currentLine)
|
||||
currentLine = word
|
||||
}
|
||||
}
|
||||
wrappedLines = append(wrappedLines, currentLine)
|
||||
}
|
||||
|
||||
// Handle lines that are too long and have no spaces
|
||||
for i, line := range wrappedLines {
|
||||
width := measureString(line, face, ctx)
|
||||
if width > maxWidth {
|
||||
var splitLines []string
|
||||
currentLine := ""
|
||||
for _, r := range line {
|
||||
testLine := currentLine + string(r)
|
||||
width := measureString(testLine, face, ctx)
|
||||
if width <= maxWidth {
|
||||
currentLine = testLine
|
||||
} else {
|
||||
splitLines = append(splitLines, currentLine)
|
||||
currentLine = string(r)
|
||||
}
|
||||
}
|
||||
splitLines = append(splitLines, currentLine)
|
||||
wrappedLines = append(wrappedLines[:i], append(splitLines, wrappedLines[i+1:]...)...)
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedLines
|
||||
}
|
||||
|
||||
func GenerateLabel(w io.Writer, params *GenerateParameters) error {
|
||||
if err := params.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create QR code
|
||||
qr, err := qrcode.New(params.URL, qrcode.Medium)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qr.DisableBorder = true
|
||||
qrImage := qr.Image(params.QrSize)
|
||||
|
||||
// Create a new white background image
|
||||
bounds := image.Rect(0, 0, params.Width, params.Height)
|
||||
img := image.NewRGBA(bounds)
|
||||
draw.Draw(img, bounds, &image.Uniform{color.White}, image.Point{}, draw.Src)
|
||||
|
||||
// Draw QR code onto the image
|
||||
draw.Draw(img,
|
||||
image.Rect(params.Margin, params.Margin, params.QrSize+params.Margin, params.QrSize+params.Margin),
|
||||
qrImage,
|
||||
image.Point{},
|
||||
draw.Over)
|
||||
|
||||
regularFont, err := truetype.Parse(gomedium.TTF)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
boldFont, err := truetype.Parse(gobold.TTF)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
regularFace := truetype.NewFace(regularFont, &truetype.Options{
|
||||
Size: params.TitleFontSize,
|
||||
DPI: params.Dpi,
|
||||
})
|
||||
boldFace := truetype.NewFace(boldFont, &truetype.Options{
|
||||
Size: params.DescriptionFontSize,
|
||||
DPI: params.Dpi,
|
||||
})
|
||||
|
||||
createContext := func(font *truetype.Font, size float64) *freetype.Context {
|
||||
c := freetype.NewContext()
|
||||
c.SetDPI(params.Dpi)
|
||||
c.SetFont(font)
|
||||
c.SetFontSize(size)
|
||||
c.SetClip(img.Bounds())
|
||||
c.SetDst(img)
|
||||
c.SetSrc(image.NewUniform(color.Black))
|
||||
return c
|
||||
}
|
||||
|
||||
boldContext := createContext(boldFont, params.TitleFontSize)
|
||||
regularContext := createContext(regularFont, params.DescriptionFontSize)
|
||||
|
||||
maxWidth := params.Width - (params.Margin * 2) - params.QrSize - params.ComponentPadding
|
||||
lineSpacing := boldContext.PointToFixed(params.TitleFontSize).Round()
|
||||
textX := params.Margin + params.ComponentPadding + params.QrSize
|
||||
textY := params.Margin - 8
|
||||
|
||||
titleLines := wrapText(params.TitleText, boldFace, maxWidth, boldContext)
|
||||
for _, line := range titleLines {
|
||||
pt := freetype.Pt(textX, textY+lineSpacing)
|
||||
_, err = boldContext.DrawString(line, pt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
textY += lineSpacing
|
||||
}
|
||||
|
||||
textY += params.ComponentPadding / 4
|
||||
lineSpacing = regularContext.PointToFixed(params.DescriptionFontSize).Round()
|
||||
|
||||
descriptionLines := wrapText(params.DescriptionText, regularFace, maxWidth, regularContext)
|
||||
for _, line := range descriptionLines {
|
||||
pt := freetype.Pt(textX, textY+lineSpacing)
|
||||
_, err = regularContext.DrawString(line, pt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
textY += lineSpacing
|
||||
}
|
||||
|
||||
err = png.Encode(w, img)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrintLabel(cfg *config.Config, params *GenerateParameters) error {
|
||||
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("label-%d.png", time.Now().UnixNano()))
|
||||
f, err := os.OpenFile(tmpFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
log.Printf("failed to remove temporary label file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = GenerateLabel(f, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.LabelMaker.PrintCommand == nil {
|
||||
return fmt.Errorf("no print command specified")
|
||||
}
|
||||
|
||||
commandTemplate := template.Must(template.New("command").Parse(*cfg.LabelMaker.PrintCommand))
|
||||
builder := &strings.Builder{}
|
||||
if err := commandTemplate.Execute(builder, map[string]string{
|
||||
"FileName": f.Name(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commandParts := strings.Fields(builder.String())
|
||||
if len(commandParts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := exec.Command(commandParts[0], commandParts[1:]...)
|
||||
|
||||
_, err = command.CombinedOutput()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
| HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled |
|
||||
| HBOX_SWAGGER_SCHEMA | `http` | swagger schema to use, can be one of: `http`, `https` |
|
||||
| HBOX_OPTIONS_CHECK_GITHUB_RELEASE | true | check for new github releases |
|
||||
| HBOX_LABEL_MAKER_WIDTH | 526 | width for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_HEIGHT | 200 | height for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_PADDING | 8 | space between elements on label |
|
||||
| HBOX_LABEL_MAKER_MARGIN | 8 | space between the label content and edges of the label |
|
||||
| HBOX_LABEL_MAKER_FONT_SIZE | 32.0 | the size of the labels font |
|
||||
| HBOX_LABEL_MAKER_PRINT_COMMAND | | the command to use for printing labels. if empty, label printing is disabled. `{{.FileName}}` in the command will be replaced with the png filename of the label |
|
||||
|
||||
::: tip "CLI Arguments"
|
||||
If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help` for more information.
|
||||
@@ -56,6 +62,12 @@ OPTIONS
|
||||
--options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID <bool> (default: true)
|
||||
--options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG <string>
|
||||
--options-check-github-release/$HBOX_OPTIONS_CHECK_GITHUB_RELEASE <bool> (default: true)
|
||||
--label-maker-width/$HBOX_LABEL_MAKER_WIDTH <int> (default: 526)
|
||||
--label-maker-height/$HBOX_LABEL_MAKER_HEIGHT <int> (default: 200)
|
||||
--label-maker-padding/$HBOX_LABEL_MAKER_PADDING <int> (default: 8)
|
||||
--label-maker-margin/$HBOX_LABEL_MAKER_MARGIN <int> (default: 8)
|
||||
--label-maker-font-size/$HBOX_LABEL_MAKER_FONT_SIZE <float> (default: 32.0)
|
||||
--label-maker-print-command/$HBOX_LABEL_MAKER_PRINT_COMMAND <string>
|
||||
--help/-h display this help message
|
||||
```
|
||||
:::
|
||||
|
||||
125
frontend/components/global/LabelMaker.vue
Normal file
125
frontend/components/global/LabelMaker.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { route } from "../../lib/api/base";
|
||||
import MdiPrinterPos from "~icons/mdi/printer-pos";
|
||||
import MdiFileDownload from "~icons/mdi/file-download";
|
||||
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const pubApi = usePublicApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const { data: status } = useAsyncData(async () => {
|
||||
const { data, error } = await pubApi.status();
|
||||
if (error) {
|
||||
toast.error("Failed to load status");
|
||||
return;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
const printModal = ref(false);
|
||||
const serverPrinting = ref(false);
|
||||
|
||||
function openPrint() {
|
||||
printModal.value = true;
|
||||
}
|
||||
|
||||
function browserPrint() {
|
||||
const printWindow = window.open(getLabelUrl(false), "popup=true");
|
||||
|
||||
if (printWindow !== null) {
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function serverPrint() {
|
||||
serverPrinting.value = true;
|
||||
try {
|
||||
await fetch(getLabelUrl(true));
|
||||
} catch (err) {
|
||||
console.error("Failed to print labels:", err);
|
||||
serverPrinting.value = false;
|
||||
toast.error("Failed to print label");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Label printed");
|
||||
printModal.value = false;
|
||||
serverPrinting.value = false;
|
||||
}
|
||||
|
||||
function downloadLabel() {
|
||||
const link = document.createElement("a");
|
||||
link.download = `label-${props.id}.png`;
|
||||
link.href = getLabelUrl(false);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function getLabelUrl(print: boolean): string {
|
||||
const params = { print };
|
||||
|
||||
if (props.type === "item") {
|
||||
return route(`/labelmaker/item/${props.id}`, params);
|
||||
} else if (props.type === "location") {
|
||||
return route(`/labelmaker/location/${props.id}`, params);
|
||||
} else if (props.type === "asset") {
|
||||
return route(`/labelmaker/asset/${props.id}`, params);
|
||||
} else {
|
||||
throw new Error(`Unexpected labelmaker type ${props.type}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseModal v-model="printModal">
|
||||
<template #title>{{ $t("components.global.label_maker.print") }}</template>
|
||||
<p>
|
||||
{{ $t("components.global.label_maker.confirm_description") }}
|
||||
</p>
|
||||
<img :src="getLabelUrl(false)" />
|
||||
<div class="modal-action">
|
||||
<BaseButton
|
||||
v-if="status?.labelPrinting || false"
|
||||
type="submit"
|
||||
:loading="serverPrinting"
|
||||
@click="serverPrint"
|
||||
>{{ $t("components.global.label_maker.server_print") }}</BaseButton
|
||||
>
|
||||
<BaseButton type="submit" @click="browserPrint">{{
|
||||
$t("components.global.label_maker.browser_print")
|
||||
}}</BaseButton>
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
<div class="dropdown dropdown-left">
|
||||
<slot>
|
||||
<label tabindex="0" class="btn btn-sm">
|
||||
{{ $t("components.global.label_maker.titles") }}
|
||||
</label>
|
||||
</slot>
|
||||
<ul class="dropdown-content menu compact rounded-box w-52 bg-base-100 shadow-lg">
|
||||
<li>
|
||||
<button @click="openPrint">
|
||||
<MdiPrinterPos name="mdi-printer-pos" class="mr-2" />
|
||||
{{ $t("components.global.label_maker.print") }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="downloadLabel">
|
||||
<MdiFileDownload name="mdi-file-download" class="mr-2" />
|
||||
{{ $t("components.global.label_maker.download") }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -130,6 +130,7 @@
|
||||
import MdiHome from "~icons/mdi/home";
|
||||
import MdiFileTree from "~icons/mdi/file-tree";
|
||||
import MdiMagnify from "~icons/mdi/magnify";
|
||||
import MdiQrcodeScan from "~icons/mdi/qrcode-scan";
|
||||
import MdiAccount from "~icons/mdi/account";
|
||||
import MdiCog from "~icons/mdi/cog";
|
||||
import MdiWrench from "~icons/mdi/wrench";
|
||||
@@ -270,6 +271,13 @@
|
||||
name: computed(() => t("menu.search")),
|
||||
to: "/items",
|
||||
},
|
||||
{
|
||||
icon: MdiQrcodeScan,
|
||||
id: 3,
|
||||
active: computed(() => route.path === "/scanner"),
|
||||
name: computed(() => t("menu.scanner")),
|
||||
to: "/scanner",
|
||||
},
|
||||
{
|
||||
icon: MdiWrench,
|
||||
id: 3,
|
||||
|
||||
@@ -409,6 +409,7 @@ export interface APISummary {
|
||||
build: Build;
|
||||
demo: boolean;
|
||||
health: boolean;
|
||||
labelPrinting: boolean;
|
||||
latest: Latest;
|
||||
message: string;
|
||||
title: string;
|
||||
|
||||
@@ -51,6 +51,14 @@
|
||||
},
|
||||
"password_score": {
|
||||
"password_strength": "Password Strength"
|
||||
},
|
||||
"label_maker": {
|
||||
"titles": "Labels",
|
||||
"server_print": "Print on Server",
|
||||
"browser_print": "Print from Browser",
|
||||
"print": "Print label",
|
||||
"download": "Download Label",
|
||||
"confirm_description": "Are you sure you want to print this label?"
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
@@ -298,7 +306,8 @@
|
||||
"maintenance": "Maintenance",
|
||||
"profile": "Profile",
|
||||
"search": "Search",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"scanner": "Scanner"
|
||||
},
|
||||
"profile": {
|
||||
"active": "Active",
|
||||
@@ -366,5 +375,12 @@
|
||||
"bill_of_materials_sub": "Generates a CSV (Comma Separated Values) file that can be imported into a spreadsheet program. This is a summary of your inventory with basic item and pricing information."
|
||||
},
|
||||
"reports_sub": "Generate different reports for your inventory."
|
||||
},
|
||||
"scanner": {
|
||||
"select_video_source": "Pick a video source",
|
||||
"unsupported": "Media Stream API is not supported",
|
||||
"error": "An error occurred while scanning",
|
||||
"no_sources": "No video sources available",
|
||||
"invalid_url": "Invalid barcode URL"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/nuxt": "^10.11.1",
|
||||
"@vueuse/router": "^10.11.1",
|
||||
"@zxing/library": "^0.21.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -519,6 +519,9 @@
|
||||
{{ $t(t.name) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<LabelMaker v-if="typeof item.assetId === 'string' && item.assetId != ''" :id="item.assetId" type="asset" />
|
||||
<LabelMaker v-else :id="item.id" type="item" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
{{ $t("global.edit") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<LabelMaker :id="location.id" type="location" />
|
||||
<BaseButton class="btn btn-sm" @click="confirmDelete()">
|
||||
<MdiDelete name="mdi-delete" class="mr-2" />
|
||||
{{ $t("global.delete") }}
|
||||
|
||||
120
frontend/pages/scanner.vue
Normal file
120
frontend/pages/scanner.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
});
|
||||
useHead({
|
||||
title: "Homebox | Scanner",
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const sources = ref<MediaDeviceInfo[]>([]);
|
||||
const selectedSource = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const video = ref<HTMLVideoElement>();
|
||||
const codeReader = new BrowserMultiFormatReader();
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
console.error("Scanner error:", error);
|
||||
errorMessage.value = t("scanner.error");
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!(navigator && navigator.mediaDevices && "enumerateDevices" in navigator.mediaDevices)) {
|
||||
errorMessage.value = t("scanner.unsupported");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await codeReader.listVideoInputDevices();
|
||||
sources.value = devices;
|
||||
|
||||
if (devices.length > 0) {
|
||||
selectedSource.value = devices[0].deviceId;
|
||||
} else {
|
||||
errorMessage.value = t("scanner.no_sources");
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// stop the code reader when navigating away
|
||||
onBeforeUnmount(() => codeReader.reset());
|
||||
|
||||
watch(selectedSource, async newSource => {
|
||||
codeReader.reset();
|
||||
|
||||
try {
|
||||
await codeReader.decodeFromVideoDevice(newSource, video.value!, (result, err) => {
|
||||
if (result && !loading.value) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const url = new URL(result.getText());
|
||||
if (!url.pathname.startsWith("/")) {
|
||||
throw new Error(t("scanner.invalid_url"));
|
||||
}
|
||||
const sanitizedPath = url.pathname.replace(/[^a-zA-Z0-9-_/]/g, "");
|
||||
navigateTo(sanitizedPath);
|
||||
} catch (err) {
|
||||
loading.value = false;
|
||||
handleError(err);
|
||||
}
|
||||
}
|
||||
if (err && !(err instanceof NotFoundException)) {
|
||||
console.error(err);
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-12 pb-16">
|
||||
<section>
|
||||
<div class="mx-auto">
|
||||
<div class="max-w-screen-md">
|
||||
<div v-if="errorMessage" role="alert" class="alert alert-error mb-5 shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="size-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm">{{ errorMessage }}</span>
|
||||
</div>
|
||||
<video ref="video" class="rounded-box shadow-lg" poster="data:image/gif,AAAA"></video>
|
||||
<select v-model="selectedSource" class="select mt-4 w-full shadow-lg">
|
||||
<option disabled selected :value="null">{{ t("scanner.select_video_source") }}</option>
|
||||
<option v-for="source in sources" :key="source.deviceId" :value="source.deviceId">
|
||||
{{ source.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
video {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
43
frontend/pnpm-lock.yaml
generated
43
frontend/pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
'@vueuse/router':
|
||||
specifier: ^10.11.1
|
||||
version: 10.11.1(vue-router@4.5.0(vue@3.4.8(typescript@5.6.2)))(vue@3.4.8(typescript@5.6.2))
|
||||
'@zxing/library':
|
||||
specifier: ^0.21.3
|
||||
version: 0.21.3
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.4.20(postcss@8.4.49)
|
||||
@@ -2120,6 +2123,13 @@ packages:
|
||||
'@vueuse/shared@12.5.0':
|
||||
resolution: {integrity: sha512-vMpcL1lStUU6O+kdj6YdHDixh0odjPAUM15uJ9f7MY781jcYkIwFA4iv2EfoIPO6vBmvutI1HxxAwmf0cx5ISQ==}
|
||||
|
||||
'@zxing/library@0.21.3':
|
||||
resolution: {integrity: sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==}
|
||||
engines: {node: '>= 10.4.0'}
|
||||
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||
|
||||
abbrev@2.0.0:
|
||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -5332,6 +5342,10 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.2.0'
|
||||
|
||||
ts-custom-error@3.3.1:
|
||||
resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
@@ -7467,7 +7481,7 @@ snapshots:
|
||||
|
||||
'@nuxtjs/eslint-config-typescript@12.1.0(eslint@8.57.1)(typescript@5.6.2)':
|
||||
dependencies:
|
||||
'@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1)
|
||||
'@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
|
||||
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2)
|
||||
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.6.2)
|
||||
eslint: 8.57.1
|
||||
@@ -7480,10 +7494,10 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1)':
|
||||
'@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)':
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
eslint-config-standard: 17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-config-standard: 17.1.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1)
|
||||
eslint-plugin-n: 15.7.0(eslint@8.57.1)
|
||||
eslint-plugin-node: 11.1.0(eslint@8.57.1)
|
||||
@@ -8363,6 +8377,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@zxing/library@0.21.3':
|
||||
dependencies:
|
||||
ts-custom-error: 3.3.1
|
||||
optionalDependencies:
|
||||
'@zxing/text-encoding': 0.9.0
|
||||
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
optional: true
|
||||
|
||||
abbrev@2.0.0: {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
@@ -9332,7 +9355,7 @@ snapshots:
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
|
||||
eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1):
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1)
|
||||
@@ -9363,7 +9386,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1):
|
||||
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
@@ -9397,7 +9420,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1)
|
||||
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -10772,7 +10795,7 @@ snapshots:
|
||||
unenv: 1.10.0
|
||||
unimport: 3.14.5(rollup@4.29.1)
|
||||
unplugin: 1.16.0
|
||||
unplugin-vue-router: 0.10.9(rollup@4.29.1)(vue-router@4.5.0(vue@3.4.8(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2))
|
||||
unplugin-vue-router: 0.10.9(rollup@4.29.1)(vue-router@4.5.0(vue@3.5.13(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2))
|
||||
unstorage: 1.14.4(db0@0.2.1)(ioredis@5.4.2)
|
||||
untyped: 1.5.2
|
||||
vue: 3.5.13(typescript@5.6.2)
|
||||
@@ -12040,6 +12063,8 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.6.2
|
||||
|
||||
ts-custom-error@3.3.1: {}
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
@@ -12204,7 +12229,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
unplugin-vue-router@0.10.9(rollup@4.29.1)(vue-router@4.5.0(vue@3.4.8(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2)):
|
||||
unplugin-vue-router@0.10.9(rollup@4.29.1)(vue-router@4.5.0(vue@3.5.13(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2)):
|
||||
dependencies:
|
||||
'@babel/types': 7.26.3
|
||||
'@rollup/pluginutils': 5.1.4(rollup@4.29.1)
|
||||
@@ -12221,7 +12246,7 @@ snapshots:
|
||||
unplugin: 2.0.0-beta.1
|
||||
yaml: 2.7.0
|
||||
optionalDependencies:
|
||||
vue-router: 4.5.0(vue@3.4.8(typescript@5.6.2))
|
||||
vue-router: 4.5.0(vue@3.5.13(typescript@5.6.2))
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- vue
|
||||
|
||||
Reference in New Issue
Block a user