add label generation api (#498)

* add label generation api

* show location name on labels

* add label scan page

* dispose of code reader when navigating away from scan page

* save label to png

* implement code suggestions

* fix label padding and margin

* update swagger docs

* add print from browser dialog

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

* increase label description font weight

* update documentation label file suffix

* fix scanner components import

* fix linting issues

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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=

View File

@@ -26,6 +26,7 @@ type Config struct {
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) {

View 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
}

View File

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

View File

@@ -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
```
:::

View 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>

View File

@@ -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,

View File

@@ -409,6 +409,7 @@ export interface APISummary {
build: Build;
demo: boolean;
health: boolean;
labelPrinting: boolean;
latest: Latest;
message: string;
title: string;

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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