diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index aae9aee6..c77cff3b 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -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, }) } } diff --git a/backend/app/api/handlers/v1/helpers.go b/backend/app/api/handlers/v1/helpers.go new file mode 100644 index 00000000..9bc318b0 --- /dev/null +++ b/backend/app/api/handlers/v1/helpers.go @@ -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() +} diff --git a/backend/app/api/handlers/v1/v1_ctrl_items.go b/backend/app/api/handlers/v1/v1_ctrl_items.go index 6b04dff3..6582adac 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_items.go +++ b/backend/app/api/handlers/v1/v1_ctrl_items.go @@ -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() -} diff --git a/backend/app/api/handlers/v1/v1_ctrl_labelmaker.go b/backend/app/api/handlers/v1/v1_ctrl_labelmaker.go new file mode 100644 index 00000000..40236b9e --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_labelmaker.go @@ -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())) + } +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 386675e0..af92f54d 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -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...)) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 9cf65996..f15f3288 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -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" }, diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 8006ab90..227fa54f 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -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" }, diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index ee6ba80e..7f3d1145 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -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: diff --git a/backend/go.mod b/backend/go.mod index 2dc6c330..ebadaa96 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index d62db989..a4194236 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index 9f8532ff..854f28c1 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -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), ), + ), ) } diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index 0d177716..0cdac6ac 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -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) { diff --git a/backend/pkgs/labelmaker/labelmaker.go b/backend/pkgs/labelmaker/labelmaker.go new file mode 100644 index 00000000..654e1036 --- /dev/null +++ b/backend/pkgs/labelmaker/labelmaker.go @@ -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 +} diff --git a/docs/docs/api/openapi-2.0.json b/docs/docs/api/openapi-2.0.json index 8006ab90..227fa54f 100644 --- a/docs/docs/api/openapi-2.0.json +++ b/docs/docs/api/openapi-2.0.json @@ -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" }, diff --git a/docs/en/configure-homebox.md b/docs/en/configure-homebox.md index e014080f..d7ceb342 100644 --- a/docs/en/configure-homebox.md +++ b/docs/en/configure-homebox.md @@ -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 (default: true) --options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG --options-check-github-release/$HBOX_OPTIONS_CHECK_GITHUB_RELEASE (default: true) +--label-maker-width/$HBOX_LABEL_MAKER_WIDTH (default: 526) +--label-maker-height/$HBOX_LABEL_MAKER_HEIGHT (default: 200) +--label-maker-padding/$HBOX_LABEL_MAKER_PADDING (default: 8) +--label-maker-margin/$HBOX_LABEL_MAKER_MARGIN (default: 8) +--label-maker-font-size/$HBOX_LABEL_MAKER_FONT_SIZE (default: 32.0) +--label-maker-print-command/$HBOX_LABEL_MAKER_PRINT_COMMAND --help/-h display this help message ``` ::: diff --git a/frontend/components/global/LabelMaker.vue b/frontend/components/global/LabelMaker.vue new file mode 100644 index 00000000..8e29b6dc --- /dev/null +++ b/frontend/components/global/LabelMaker.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 86069b46..8f452d37 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -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, diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 97b3d8be..119de96b 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -409,6 +409,7 @@ export interface APISummary { build: Build; demo: boolean; health: boolean; + labelPrinting: boolean; latest: Latest; message: string; title: string; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b22dd2f8..bfbb3633 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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" } } diff --git a/frontend/package.json b/frontend/package.json index 64240bb4..6b4d0d44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 473072c8..39371bf0 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -519,6 +519,9 @@ {{ $t(t.name) }} + + + diff --git a/frontend/pages/location/[id].vue b/frontend/pages/location/[id].vue index 7e9bd991..8ac048c6 100644 --- a/frontend/pages/location/[id].vue +++ b/frontend/pages/location/[id].vue @@ -175,6 +175,7 @@ {{ $t("global.edit") }} + {{ $t("global.delete") }} diff --git a/frontend/pages/scanner.vue b/frontend/pages/scanner.vue new file mode 100644 index 00000000..e11b2847 --- /dev/null +++ b/frontend/pages/scanner.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5a3335d5..ad2080f6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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