diff --git a/backend/app/api/demo.go b/backend/app/api/demo.go index 54b04f071..d49e15c6a 100644 --- a/backend/app/api/demo.go +++ b/backend/app/api/demo.go @@ -11,7 +11,7 @@ import ( ) func (a *app) SetupDemo() error { - csvText := `HB.import_ref,HB.location,HB.labels,HB.quantity,HB.name,HB.description,HB.insured,HB.serial_number,HB.model_number,HB.manufacturer,HB.notes,HB.purchase_from,HB.purchase_price,HB.purchase_time,HB.lifetime_warranty,HB.warranty_expires,HB.warranty_details,HB.sold_to,HB.sold_price,HB.sold_time,HB.sold_notes + csvText := `HB.import_ref,HB.location,HB.labels,HB.quantity,HB.name,HB.description,HB.insured,HB.serial_number,HB.model_number,HB.manufacturer,HB.barcode,HB.notes,HB.purchase_from,HB.purchase_price,HB.purchase_time,HB.lifetime_warranty,HB.warranty_expires,HB.warranty_details,HB.sold_to,HB.sold_price,HB.sold_time,HB.sold_notes ,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,,,,,, ,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,,,,,, ,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,, diff --git a/backend/app/api/handlers/v1/v1_ctrl_barcodes.go b/backend/app/api/handlers/v1/v1_ctrl_barcodes.go new file mode 100644 index 000000000..4c02f5b42 --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_barcodes.go @@ -0,0 +1,44 @@ +package v1 + +import ( + "net/http" + + "github.com/hay-kot/httpkit/errchain" + "github.com/hay-kot/httpkit/server" + "github.com/sysadminsmedia/homebox/backend/internal/web/adapters" +) + +// HandleGenerateQRCode godoc +// +// @Summary Search EAN from Barcode +// @Tags Items +// @Produce json +// @Param data query string false "barcode to be searched" +// @Success 200 {object} []repo.BarcodeProduct +// @Router /v1/products/search-from-barcode [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleProductSearchFromBarcode() errchain.HandlerFunc { + type query struct { + // 80 characters is the longest non-2D barcode length (GS1-128) + EAN string `schema:"productEAN" validate:"required,max=80"` + } + + return func(w http.ResponseWriter, r *http.Request) error { + q, err := adapters.DecodeQuery[query](r) + if err != nil { + return err + } + + products, err := ctrl.repo.Barcode.RetrieveProductsFromBarcode(ctrl.config.Barcode, q.EAN) + + if err != nil { + return server.JSON(w, http.StatusInternalServerError, err.Error()) + } + + if len(products) != 0 { + return server.JSON(w, http.StatusOK, products) + } + + return server.JSON(w, http.StatusNoContent, nil) + } +} diff --git a/backend/app/api/handlers/v1/v1_ctrl_product_search.go b/backend/app/api/handlers/v1/v1_ctrl_product_search.go deleted file mode 100644 index 248cf87e2..000000000 --- a/backend/app/api/handlers/v1/v1_ctrl_product_search.go +++ /dev/null @@ -1,332 +0,0 @@ -package v1 - -import ( - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/hay-kot/httpkit/errchain" - "github.com/hay-kot/httpkit/server" - "github.com/rs/zerolog/log" - "github.com/sysadminsmedia/homebox/backend/internal/data/repo" - "github.com/sysadminsmedia/homebox/backend/internal/sys/config" - "github.com/sysadminsmedia/homebox/backend/internal/web/adapters" -) - -type UPCITEMDBResponse struct { - Code string `json:"code"` - Total int `json:"total"` - Offset int `json:"offset"` - Items []struct { - Ean string `json:"ean"` - Title string `json:"title"` - Description string `json:"description"` - Upc string `json:"upc"` - Brand string `json:"brand"` - Model string `json:"model"` - Color string `json:"color"` - Size string `json:"size"` - Dimension string `json:"dimension"` - Weight string `json:"weight"` - Category string `json:"category"` - LowestRecordedPrice float64 `json:"lowest_recorded_price"` - HighestRecordedPrice float64 `json:"highest_recorded_price"` - Images []string `json:"images"` - Offers []struct { - Merchant string `json:"merchant"` - Domain string `json:"domain"` - Title string `json:"title"` - Currency string `json:"currency"` - ListPrice string `json:"list_price"` - Price float64 `json:"price"` - Shipping string `json:"shipping"` - Condition string `json:"condition"` - Availability string `json:"availability"` - Link string `json:"link"` - UpdatedT int `json:"updated_t"` - } `json:"offers"` - Asin string `json:"asin"` - Elid string `json:"elid"` - } `json:"items"` -} - -type BARCODESPIDER_COMResponse struct { - ItemResponse struct { - Code int `json:"code"` - Status string `json:"status"` - Message string `json:"message"` - } `json:"item_response"` - ItemAttributes struct { - Title string `json:"title"` - Upc string `json:"upc"` - Ean string `json:"ean"` - ParentCategory string `json:"parent_category"` - Category string `json:"category"` - Brand string `json:"brand"` - Model string `json:"model"` - Mpn string `json:"mpn"` - Manufacturer string `json:"manufacturer"` - Publisher string `json:"publisher"` - Asin string `json:"asin"` - Color string `json:"color"` - Size string `json:"size"` - Weight string `json:"weight"` - Image string `json:"image"` - IsAdult string `json:"is_adult"` - Description string `json:"description"` - } `json:"item_attributes"` - Stores []struct { - StoreName string `json:"store_name"` - Title string `json:"title"` - Image string `json:"image"` - Price string `json:"price"` - Currency string `json:"currency"` - Link string `json:"link"` - Updated string `json:"updated"` - } `json:"Stores"` -} - -// HandleGenerateQRCode godoc -// -// @Summary Search EAN from Barcode -// @Tags Items -// @Produce json -// @Param data query string false "barcode to be searched" -// @Success 200 {object} []repo.BarcodeProduct -// @Router /v1/products/search-from-barcode [GET] -// @Security Bearer -func (ctrl *V1Controller) HandleProductSearchFromBarcode(conf config.BarcodeAPIConf) errchain.HandlerFunc { - type query struct { - // 80 characters is the longest non-2D barcode length (GS1-128) - EAN string `schema:"productEAN" validate:"required,max=80"` - } - - return func(w http.ResponseWriter, r *http.Request) error { - q, err := adapters.DecodeQuery[query](r) - if err != nil { - return err - } - - const TIMEOUT_SEC = 10 - - log.Info().Msg("Processing barcode lookup request on: " + q.EAN) - - // Search on UPCITEMDB - var products []repo.BarcodeProduct - - // www.ean-search.org/: not free - - // Example code: dewalt 5035048748428 - - upcitemdb := func(iEan string) ([]repo.BarcodeProduct, error) { - client := &http.Client{Timeout: TIMEOUT_SEC * time.Second} - resp, err := client.Get("https://api.upcitemdb.com/prod/trial/lookup?upc=" + iEan) - if err != nil { - return nil, err - } - - defer func() { - err = errors.Join(err, resp.Body.Close()) - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) - } - - // We Read the response body on the line below. - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - // Uncomment the following string for debug - // sb := string(body) - // log.Debug().Msg("Response: " + sb) - - var result UPCITEMDBResponse - if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer - log.Error().Msg("Can not unmarshal JSON") - } - - var res []repo.BarcodeProduct - - for _, it := range result.Items { - var p repo.BarcodeProduct - p.SearchEngineName = "upcitemdb.com" - p.Barcode = iEan - - p.Item.Description = it.Description - p.Item.Name = it.Title - p.Manufacturer = it.Brand - p.ModelNumber = it.Model - if len(it.Images) != 0 { - p.ImageURL = it.Images[0] - } - - res = append(res, p) - } - - return res, nil - } - - ps, err := upcitemdb(q.EAN) - if err != nil { - log.Error().Msg("Can not retrieve product from upcitemdb.com" + err.Error()) - } - - // Barcode spider implementation - barcodespider := func(tokenAPI string, iEan string) ([]repo.BarcodeProduct, error) { - if len(tokenAPI) == 0 { - return nil, errors.New("no api token configured for barcodespider. " + - "Please define the api token in environment variable HBOX_BARCODE_TOKEN_BARCODESPIDER") - } - - req, err := http.NewRequest( - "GET", "https://api.barcodespider.com/v1/lookup?upc="+iEan, nil) - - if err != nil { - return nil, err - } - - req.Header.Add("token", tokenAPI) - - client := &http.Client{Timeout: TIMEOUT_SEC * time.Second} - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - - // defer the call to Body.Close(). We also check the error code, and merge - // it with the other error in this code to avoid error overiding. - defer func() { - err = errors.Join(err, resp.Body.Close()) - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("barcodespider API returned status code: %d", resp.StatusCode) - } - - // We Read the response body on the line below. - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - // Uncomment the following string for debug - // sb := string(body) - // log.Debug().Msg("Response: " + sb) - - var result BARCODESPIDER_COMResponse - if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer - log.Error().Msg("Can not unmarshal JSON") - } - - // TODO: check 200 code on HTTP response. - var p repo.BarcodeProduct - p.Barcode = iEan - p.SearchEngineName = "barcodespider.com" - p.Item.Name = result.ItemAttributes.Title - p.Item.Description = result.ItemAttributes.Description - p.Manufacturer = result.ItemAttributes.Brand - p.ModelNumber = result.ItemAttributes.Model - p.ImageURL = result.ItemAttributes.Image - - var res []repo.BarcodeProduct - res = append(res, p) - - return res, nil - } - - ps2, err := barcodespider(conf.TokenBarcodespider, q.EAN) - if err != nil { - log.Error().Msg("Can not retrieve product from barcodespider.com: " + err.Error()) - } - - // Merge everything. - products = append(products, ps...) - - products = append(products, ps2...) - - // Retrieve images if possible - for i := range products { - p := &products[i] - - if len(p.ImageURL) == 0 { - continue - } - - // Validate URL is HTTPS - u, err := url.Parse(p.ImageURL) - if err != nil || u.Scheme != "https" { - log.Warn().Msg("Skipping non-HTTPS image URL: " + p.ImageURL) - continue - } - - client := &http.Client{Timeout: TIMEOUT_SEC * time.Second} - res, err := client.Get(p.ImageURL) - if err != nil { - log.Warn().Msg("Cannot fetch image for URL: " + p.ImageURL + ": " + err.Error()) - } - - defer func() { - err = errors.Join(err, res.Body.Close()) - }() - - // Validate response - if res.StatusCode != http.StatusOK { - continue - } - - // Check content type - contentType := res.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "image/") { - continue - } - - // Limit image size to 8MB - limitedReader := io.LimitReader(res.Body, 8*1024*1024) - - // Read data of image - bytes, err := io.ReadAll(limitedReader) - if err != nil { - log.Warn().Msg(err.Error()) - continue - } - - // Convert to Base64 - var base64Encoding string - - // Determine the content type of the image file - mimeType := http.DetectContentType(bytes) - - // Prepend the appropriate URI scheme header depending - // on the MIME type - switch mimeType { - case "image/jpeg": - base64Encoding += "data:image/jpeg;base64," - case "image/png": - base64Encoding += "data:image/png;base64," - default: - continue - } - - // Append the base64 encoded output - base64Encoding += base64.StdEncoding.EncodeToString(bytes) - - p.ImageBase64 = base64Encoding - } - - if len(products) != 0 { - return server.JSON(w, http.StatusOK, products) - } - - return server.JSON(w, http.StatusNoContent, nil) - } -} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 5f6a5ca6b..ccfc521c5 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -158,7 +158,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR a.mwRoles(RoleModeOr, authroles.RoleUser.String(), authroles.RoleAttachments.String()), } - r.Get("/products/search-from-barcode", chain.ToHandlerFunc(v1Ctrl.HandleProductSearchFromBarcode(a.conf.Barcode), userMW...)) + r.Get("/products/search-from-barcode", chain.ToHandlerFunc(v1Ctrl.HandleProductSearchFromBarcode(), userMW...)) r.Get("/qrcode", chain.ToHandlerFunc(v1Ctrl.HandleGenerateQRCode(), assetMW...)) r.Get( diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 843aebf91..14f3a0a88 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -2560,6 +2560,10 @@ const docTemplate = `{ "description": "AssetID holds the value of the \"asset_id\" field.", "type": "integer" }, + "barcode": { + "description": "Barcode holds the value of the \"barcode\" field.", + "type": "string" + }, "created_at": { "description": "CreatedAt holds the value of the \"created_at\" field.", "type": "string" @@ -3143,9 +3147,6 @@ const docTemplate = `{ "repo.BarcodeProduct": { "type": "object", "properties": { - "barcode": { - "type": "string" - }, "imageBase64": { "type": "string" }, @@ -3155,18 +3156,17 @@ const docTemplate = `{ "item": { "$ref": "#/definitions/repo.ItemCreate" }, - "manufacturer": { + "notes": { + "description": "Extras", "type": "string" }, - "modelNumber": { - "description": "Identifications", + "search_engine_name": { "type": "string" }, - "notes": { - "description": "Extras", + "search_engine_product_url": { "type": "string" }, - "search_engine_name": { + "search_engine_url": { "type": "string" } } @@ -3294,6 +3294,9 @@ const docTemplate = `{ "name" ], "properties": { + "barcode": { + "type": "string" + }, "description": { "type": "string", "maxLength": 1000 @@ -3308,6 +3311,12 @@ const docTemplate = `{ "description": "Edges", "type": "string" }, + "manufacturer": { + "type": "string" + }, + "modelNumber": { + "type": "string" + }, "name": { "type": "string", "maxLength": 255, @@ -3361,6 +3370,9 @@ const docTemplate = `{ "$ref": "#/definitions/repo.ItemAttachment" } }, + "barcode": { + "type": "string" + }, "createdAt": { "type": "string" }, @@ -3590,6 +3602,9 @@ const docTemplate = `{ "assetId": { "type": "string" }, + "barcode": { + "type": "string" + }, "description": { "type": "string", "maxLength": 1000 diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index c62359b92..4e0d2095d 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -2558,6 +2558,10 @@ "description": "AssetID holds the value of the \"asset_id\" field.", "type": "integer" }, + "barcode": { + "description": "Barcode holds the value of the \"barcode\" field.", + "type": "string" + }, "created_at": { "description": "CreatedAt holds the value of the \"created_at\" field.", "type": "string" @@ -3141,9 +3145,6 @@ "repo.BarcodeProduct": { "type": "object", "properties": { - "barcode": { - "type": "string" - }, "imageBase64": { "type": "string" }, @@ -3153,18 +3154,17 @@ "item": { "$ref": "#/definitions/repo.ItemCreate" }, - "manufacturer": { + "notes": { + "description": "Extras", "type": "string" }, - "modelNumber": { - "description": "Identifications", + "search_engine_name": { "type": "string" }, - "notes": { - "description": "Extras", + "search_engine_product_url": { "type": "string" }, - "search_engine_name": { + "search_engine_url": { "type": "string" } } @@ -3292,6 +3292,9 @@ "name" ], "properties": { + "barcode": { + "type": "string" + }, "description": { "type": "string", "maxLength": 1000 @@ -3306,6 +3309,12 @@ "description": "Edges", "type": "string" }, + "manufacturer": { + "type": "string" + }, + "modelNumber": { + "type": "string" + }, "name": { "type": "string", "maxLength": 255, @@ -3359,6 +3368,9 @@ "$ref": "#/definitions/repo.ItemAttachment" } }, + "barcode": { + "type": "string" + }, "createdAt": { "type": "string" }, @@ -3588,6 +3600,9 @@ "assetId": { "type": "string" }, + "barcode": { + "type": "string" + }, "description": { "type": "string", "maxLength": 1000 diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index 8289cc185..c503effc6 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -247,6 +247,9 @@ definitions: asset_id: description: AssetID holds the value of the "asset_id" field. type: integer + barcode: + description: Barcode holds the value of the "barcode" field. + type: string created_at: description: CreatedAt holds the value of the "created_at" field. type: string @@ -648,24 +651,21 @@ definitions: - TypeTime repo.BarcodeProduct: properties: - barcode: - type: string imageBase64: type: string imageURL: type: string item: $ref: '#/definitions/repo.ItemCreate' - manufacturer: - type: string - modelNumber: - description: Identifications - type: string notes: description: Extras type: string search_engine_name: type: string + search_engine_product_url: + type: string + search_engine_url: + type: string type: object repo.DuplicateOptions: properties: @@ -745,6 +745,8 @@ definitions: type: object repo.ItemCreate: properties: + barcode: + type: string description: maxLength: 1000 type: string @@ -755,6 +757,10 @@ definitions: locationId: description: Edges type: string + manufacturer: + type: string + modelNumber: + type: string name: maxLength: 255 minLength: 1 @@ -793,6 +799,8 @@ definitions: items: $ref: '#/definitions/repo.ItemAttachment' type: array + barcode: + type: string createdAt: type: string description: @@ -946,6 +954,8 @@ definitions: type: boolean assetId: type: string + barcode: + type: string description: maxLength: 1000 type: string diff --git a/backend/internal/core/services/reporting/io_row.go b/backend/internal/core/services/reporting/io_row.go index 1e3415b49..01ae6a288 100644 --- a/backend/internal/core/services/reporting/io_row.go +++ b/backend/internal/core/services/reporting/io_row.go @@ -33,6 +33,7 @@ type ExportCSVRow struct { Manufacturer string `csv:"HB.manufacturer"` ModelNumber string `csv:"HB.model_number"` SerialNumber string `csv:"HB.serial_number"` + Barcode string `csv:"HB.barcode"` LifetimeWarranty bool `csv:"HB.lifetime_warranty"` WarrantyExpires types.Date `csv:"HB.warranty_expires"` diff --git a/backend/internal/data/ent/item.go b/backend/internal/data/ent/item.go index 07712bd15..b93d33aeb 100644 --- a/backend/internal/data/ent/item.go +++ b/backend/internal/data/ent/item.go @@ -48,6 +48,8 @@ type Item struct { ModelNumber string `json:"model_number,omitempty"` // Manufacturer holds the value of the "manufacturer" field. Manufacturer string `json:"manufacturer,omitempty"` + // Barcode holds the value of the "barcode" field. + Barcode string `json:"barcode,omitempty"` // LifetimeWarranty holds the value of the "lifetime_warranty" field. LifetimeWarranty bool `json:"lifetime_warranty,omitempty"` // WarrantyExpires holds the value of the "warranty_expires" field. @@ -189,7 +191,7 @@ func (*Item) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case item.FieldQuantity, item.FieldAssetID: values[i] = new(sql.NullInt64) - case item.FieldName, item.FieldDescription, item.FieldImportRef, item.FieldNotes, item.FieldSerialNumber, item.FieldModelNumber, item.FieldManufacturer, item.FieldWarrantyDetails, item.FieldPurchaseFrom, item.FieldSoldTo, item.FieldSoldNotes: + case item.FieldName, item.FieldDescription, item.FieldImportRef, item.FieldNotes, item.FieldSerialNumber, item.FieldModelNumber, item.FieldManufacturer, item.FieldBarcode, item.FieldWarrantyDetails, item.FieldPurchaseFrom, item.FieldSoldTo, item.FieldSoldNotes: values[i] = new(sql.NullString) case item.FieldCreatedAt, item.FieldUpdatedAt, item.FieldWarrantyExpires, item.FieldPurchaseTime, item.FieldSoldTime: values[i] = new(sql.NullTime) @@ -306,6 +308,12 @@ func (i *Item) assignValues(columns []string, values []any) error { } else if value.Valid { i.Manufacturer = value.String } + case item.FieldBarcode: + if value, ok := values[j].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field barcode", values[j]) + } else if value.Valid { + i.Barcode = value.String + } case item.FieldLifetimeWarranty: if value, ok := values[j].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field lifetime_warranty", values[j]) @@ -505,6 +513,9 @@ func (i *Item) String() string { builder.WriteString("manufacturer=") builder.WriteString(i.Manufacturer) builder.WriteString(", ") + builder.WriteString("barcode=") + builder.WriteString(i.Barcode) + builder.WriteString(", ") builder.WriteString("lifetime_warranty=") builder.WriteString(fmt.Sprintf("%v", i.LifetimeWarranty)) builder.WriteString(", ") diff --git a/backend/internal/data/ent/item/item.go b/backend/internal/data/ent/item/item.go index d3601c646..7016442b0 100644 --- a/backend/internal/data/ent/item/item.go +++ b/backend/internal/data/ent/item/item.go @@ -43,6 +43,8 @@ const ( FieldModelNumber = "model_number" // FieldManufacturer holds the string denoting the manufacturer field in the database. FieldManufacturer = "manufacturer" + // FieldBarcode holds the string denoting the barcode field in the database. + FieldBarcode = "barcode" // FieldLifetimeWarranty holds the string denoting the lifetime_warranty field in the database. FieldLifetimeWarranty = "lifetime_warranty" // FieldWarrantyExpires holds the string denoting the warranty_expires field in the database. @@ -148,6 +150,7 @@ var Columns = []string{ FieldSerialNumber, FieldModelNumber, FieldManufacturer, + FieldBarcode, FieldLifetimeWarranty, FieldWarrantyExpires, FieldWarrantyDetails, @@ -220,6 +223,8 @@ var ( ModelNumberValidator func(string) error // ManufacturerValidator is a validator for the "manufacturer" field. It is called by the builders before save. ManufacturerValidator func(string) error + // BarcodeValidator is a validator for the "barcode" field. It is called by the builders before save. + BarcodeValidator func(string) error // DefaultLifetimeWarranty holds the default value on creation for the "lifetime_warranty" field. DefaultLifetimeWarranty bool // WarrantyDetailsValidator is a validator for the "warranty_details" field. It is called by the builders before save. @@ -312,6 +317,11 @@ func ByManufacturer(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldManufacturer, opts...).ToFunc() } +// ByBarcode orders the results by the barcode field. +func ByBarcode(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBarcode, opts...).ToFunc() +} + // ByLifetimeWarranty orders the results by the lifetime_warranty field. func ByLifetimeWarranty(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldLifetimeWarranty, opts...).ToFunc() diff --git a/backend/internal/data/ent/item/where.go b/backend/internal/data/ent/item/where.go index 5166fa54b..43ad295db 100644 --- a/backend/internal/data/ent/item/where.go +++ b/backend/internal/data/ent/item/where.go @@ -126,6 +126,11 @@ func Manufacturer(v string) predicate.Item { return predicate.Item(sql.FieldEQ(FieldManufacturer, v)) } +// Barcode applies equality check predicate on the "barcode" field. It's identical to BarcodeEQ. +func Barcode(v string) predicate.Item { + return predicate.Item(sql.FieldEQ(FieldBarcode, v)) +} + // LifetimeWarranty applies equality check predicate on the "lifetime_warranty" field. It's identical to LifetimeWarrantyEQ. func LifetimeWarranty(v bool) predicate.Item { return predicate.Item(sql.FieldEQ(FieldLifetimeWarranty, v)) @@ -881,6 +886,81 @@ func ManufacturerContainsFold(v string) predicate.Item { return predicate.Item(sql.FieldContainsFold(FieldManufacturer, v)) } +// BarcodeEQ applies the EQ predicate on the "barcode" field. +func BarcodeEQ(v string) predicate.Item { + return predicate.Item(sql.FieldEQ(FieldBarcode, v)) +} + +// BarcodeNEQ applies the NEQ predicate on the "barcode" field. +func BarcodeNEQ(v string) predicate.Item { + return predicate.Item(sql.FieldNEQ(FieldBarcode, v)) +} + +// BarcodeIn applies the In predicate on the "barcode" field. +func BarcodeIn(vs ...string) predicate.Item { + return predicate.Item(sql.FieldIn(FieldBarcode, vs...)) +} + +// BarcodeNotIn applies the NotIn predicate on the "barcode" field. +func BarcodeNotIn(vs ...string) predicate.Item { + return predicate.Item(sql.FieldNotIn(FieldBarcode, vs...)) +} + +// BarcodeGT applies the GT predicate on the "barcode" field. +func BarcodeGT(v string) predicate.Item { + return predicate.Item(sql.FieldGT(FieldBarcode, v)) +} + +// BarcodeGTE applies the GTE predicate on the "barcode" field. +func BarcodeGTE(v string) predicate.Item { + return predicate.Item(sql.FieldGTE(FieldBarcode, v)) +} + +// BarcodeLT applies the LT predicate on the "barcode" field. +func BarcodeLT(v string) predicate.Item { + return predicate.Item(sql.FieldLT(FieldBarcode, v)) +} + +// BarcodeLTE applies the LTE predicate on the "barcode" field. +func BarcodeLTE(v string) predicate.Item { + return predicate.Item(sql.FieldLTE(FieldBarcode, v)) +} + +// BarcodeContains applies the Contains predicate on the "barcode" field. +func BarcodeContains(v string) predicate.Item { + return predicate.Item(sql.FieldContains(FieldBarcode, v)) +} + +// BarcodeHasPrefix applies the HasPrefix predicate on the "barcode" field. +func BarcodeHasPrefix(v string) predicate.Item { + return predicate.Item(sql.FieldHasPrefix(FieldBarcode, v)) +} + +// BarcodeHasSuffix applies the HasSuffix predicate on the "barcode" field. +func BarcodeHasSuffix(v string) predicate.Item { + return predicate.Item(sql.FieldHasSuffix(FieldBarcode, v)) +} + +// BarcodeIsNil applies the IsNil predicate on the "barcode" field. +func BarcodeIsNil() predicate.Item { + return predicate.Item(sql.FieldIsNull(FieldBarcode)) +} + +// BarcodeNotNil applies the NotNil predicate on the "barcode" field. +func BarcodeNotNil() predicate.Item { + return predicate.Item(sql.FieldNotNull(FieldBarcode)) +} + +// BarcodeEqualFold applies the EqualFold predicate on the "barcode" field. +func BarcodeEqualFold(v string) predicate.Item { + return predicate.Item(sql.FieldEqualFold(FieldBarcode, v)) +} + +// BarcodeContainsFold applies the ContainsFold predicate on the "barcode" field. +func BarcodeContainsFold(v string) predicate.Item { + return predicate.Item(sql.FieldContainsFold(FieldBarcode, v)) +} + // LifetimeWarrantyEQ applies the EQ predicate on the "lifetime_warranty" field. func LifetimeWarrantyEQ(v bool) predicate.Item { return predicate.Item(sql.FieldEQ(FieldLifetimeWarranty, v)) diff --git a/backend/internal/data/ent/item_create.go b/backend/internal/data/ent/item_create.go index a9b79cdde..75760e8db 100644 --- a/backend/internal/data/ent/item_create.go +++ b/backend/internal/data/ent/item_create.go @@ -215,6 +215,20 @@ func (ic *ItemCreate) SetNillableManufacturer(s *string) *ItemCreate { return ic } +// SetBarcode sets the "barcode" field. +func (ic *ItemCreate) SetBarcode(s string) *ItemCreate { + ic.mutation.SetBarcode(s) + return ic +} + +// SetNillableBarcode sets the "barcode" field if the given value is not nil. +func (ic *ItemCreate) SetNillableBarcode(s *string) *ItemCreate { + if s != nil { + ic.SetBarcode(*s) + } + return ic +} + // SetLifetimeWarranty sets the "lifetime_warranty" field. func (ic *ItemCreate) SetLifetimeWarranty(b bool) *ItemCreate { ic.mutation.SetLifetimeWarranty(b) @@ -635,6 +649,11 @@ func (ic *ItemCreate) check() error { return &ValidationError{Name: "manufacturer", err: fmt.Errorf(`ent: validator failed for field "Item.manufacturer": %w`, err)} } } + if v, ok := ic.mutation.Barcode(); ok { + if err := item.BarcodeValidator(v); err != nil { + return &ValidationError{Name: "barcode", err: fmt.Errorf(`ent: validator failed for field "Item.barcode": %w`, err)} + } + } if _, ok := ic.mutation.LifetimeWarranty(); !ok { return &ValidationError{Name: "lifetime_warranty", err: errors.New(`ent: missing required field "Item.lifetime_warranty"`)} } @@ -748,6 +767,10 @@ func (ic *ItemCreate) createSpec() (*Item, *sqlgraph.CreateSpec) { _spec.SetField(item.FieldManufacturer, field.TypeString, value) _node.Manufacturer = value } + if value, ok := ic.mutation.Barcode(); ok { + _spec.SetField(item.FieldBarcode, field.TypeString, value) + _node.Barcode = value + } if value, ok := ic.mutation.LifetimeWarranty(); ok { _spec.SetField(item.FieldLifetimeWarranty, field.TypeBool, value) _node.LifetimeWarranty = value diff --git a/backend/internal/data/ent/item_update.go b/backend/internal/data/ent/item_update.go index 844eaf607..08983014d 100644 --- a/backend/internal/data/ent/item_update.go +++ b/backend/internal/data/ent/item_update.go @@ -259,6 +259,26 @@ func (iu *ItemUpdate) ClearManufacturer() *ItemUpdate { return iu } +// SetBarcode sets the "barcode" field. +func (iu *ItemUpdate) SetBarcode(s string) *ItemUpdate { + iu.mutation.SetBarcode(s) + return iu +} + +// SetNillableBarcode sets the "barcode" field if the given value is not nil. +func (iu *ItemUpdate) SetNillableBarcode(s *string) *ItemUpdate { + if s != nil { + iu.SetBarcode(*s) + } + return iu +} + +// ClearBarcode clears the value of the "barcode" field. +func (iu *ItemUpdate) ClearBarcode() *ItemUpdate { + iu.mutation.ClearBarcode() + return iu +} + // SetLifetimeWarranty sets the "lifetime_warranty" field. func (iu *ItemUpdate) SetLifetimeWarranty(b bool) *ItemUpdate { iu.mutation.SetLifetimeWarranty(b) @@ -780,6 +800,11 @@ func (iu *ItemUpdate) check() error { return &ValidationError{Name: "manufacturer", err: fmt.Errorf(`ent: validator failed for field "Item.manufacturer": %w`, err)} } } + if v, ok := iu.mutation.Barcode(); ok { + if err := item.BarcodeValidator(v); err != nil { + return &ValidationError{Name: "barcode", err: fmt.Errorf(`ent: validator failed for field "Item.barcode": %w`, err)} + } + } if v, ok := iu.mutation.WarrantyDetails(); ok { if err := item.WarrantyDetailsValidator(v); err != nil { return &ValidationError{Name: "warranty_details", err: fmt.Errorf(`ent: validator failed for field "Item.warranty_details": %w`, err)} @@ -871,6 +896,12 @@ func (iu *ItemUpdate) sqlSave(ctx context.Context) (n int, err error) { if iu.mutation.ManufacturerCleared() { _spec.ClearField(item.FieldManufacturer, field.TypeString) } + if value, ok := iu.mutation.Barcode(); ok { + _spec.SetField(item.FieldBarcode, field.TypeString, value) + } + if iu.mutation.BarcodeCleared() { + _spec.ClearField(item.FieldBarcode, field.TypeString) + } if value, ok := iu.mutation.LifetimeWarranty(); ok { _spec.SetField(item.FieldLifetimeWarranty, field.TypeBool, value) } @@ -1484,6 +1515,26 @@ func (iuo *ItemUpdateOne) ClearManufacturer() *ItemUpdateOne { return iuo } +// SetBarcode sets the "barcode" field. +func (iuo *ItemUpdateOne) SetBarcode(s string) *ItemUpdateOne { + iuo.mutation.SetBarcode(s) + return iuo +} + +// SetNillableBarcode sets the "barcode" field if the given value is not nil. +func (iuo *ItemUpdateOne) SetNillableBarcode(s *string) *ItemUpdateOne { + if s != nil { + iuo.SetBarcode(*s) + } + return iuo +} + +// ClearBarcode clears the value of the "barcode" field. +func (iuo *ItemUpdateOne) ClearBarcode() *ItemUpdateOne { + iuo.mutation.ClearBarcode() + return iuo +} + // SetLifetimeWarranty sets the "lifetime_warranty" field. func (iuo *ItemUpdateOne) SetLifetimeWarranty(b bool) *ItemUpdateOne { iuo.mutation.SetLifetimeWarranty(b) @@ -2018,6 +2069,11 @@ func (iuo *ItemUpdateOne) check() error { return &ValidationError{Name: "manufacturer", err: fmt.Errorf(`ent: validator failed for field "Item.manufacturer": %w`, err)} } } + if v, ok := iuo.mutation.Barcode(); ok { + if err := item.BarcodeValidator(v); err != nil { + return &ValidationError{Name: "barcode", err: fmt.Errorf(`ent: validator failed for field "Item.barcode": %w`, err)} + } + } if v, ok := iuo.mutation.WarrantyDetails(); ok { if err := item.WarrantyDetailsValidator(v); err != nil { return &ValidationError{Name: "warranty_details", err: fmt.Errorf(`ent: validator failed for field "Item.warranty_details": %w`, err)} @@ -2126,6 +2182,12 @@ func (iuo *ItemUpdateOne) sqlSave(ctx context.Context) (_node *Item, err error) if iuo.mutation.ManufacturerCleared() { _spec.ClearField(item.FieldManufacturer, field.TypeString) } + if value, ok := iuo.mutation.Barcode(); ok { + _spec.SetField(item.FieldBarcode, field.TypeString, value) + } + if iuo.mutation.BarcodeCleared() { + _spec.ClearField(item.FieldBarcode, field.TypeString) + } if value, ok := iuo.mutation.LifetimeWarranty(); ok { _spec.SetField(item.FieldLifetimeWarranty, field.TypeBool, value) } diff --git a/backend/internal/data/ent/migrate/schema.go b/backend/internal/data/ent/migrate/schema.go index 55a2d8969..a068c17ae 100644 --- a/backend/internal/data/ent/migrate/schema.go +++ b/backend/internal/data/ent/migrate/schema.go @@ -146,6 +146,7 @@ var ( {Name: "serial_number", Type: field.TypeString, Nullable: true, Size: 255}, {Name: "model_number", Type: field.TypeString, Nullable: true, Size: 255}, {Name: "manufacturer", Type: field.TypeString, Nullable: true, Size: 255}, + {Name: "barcode", Type: field.TypeString, Nullable: true, Size: 255}, {Name: "lifetime_warranty", Type: field.TypeBool, Default: false}, {Name: "warranty_expires", Type: field.TypeTime, Nullable: true}, {Name: "warranty_details", Type: field.TypeString, Nullable: true, Size: 1000}, @@ -168,19 +169,19 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "items_groups_items", - Columns: []*schema.Column{ItemsColumns[25]}, + Columns: []*schema.Column{ItemsColumns[26]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "items_items_children", - Columns: []*schema.Column{ItemsColumns[26]}, + Columns: []*schema.Column{ItemsColumns[27]}, RefColumns: []*schema.Column{ItemsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "items_locations_items", - Columns: []*schema.Column{ItemsColumns[27]}, + Columns: []*schema.Column{ItemsColumns[28]}, RefColumns: []*schema.Column{LocationsColumns[0]}, OnDelete: schema.Cascade, }, @@ -206,6 +207,11 @@ var ( Unique: false, Columns: []*schema.Column{ItemsColumns[12]}, }, + { + Name: "item_barcode", + Unique: false, + Columns: []*schema.Column{ItemsColumns[15]}, + }, { Name: "item_archived", Unique: false, diff --git a/backend/internal/data/ent/mutation.go b/backend/internal/data/ent/mutation.go index 7f07202bd..096e923b6 100644 --- a/backend/internal/data/ent/mutation.go +++ b/backend/internal/data/ent/mutation.go @@ -3520,6 +3520,7 @@ type ItemMutation struct { serial_number *string model_number *string manufacturer *string + barcode *string lifetime_warranty *bool warranty_expires *time.Time warranty_details *string @@ -4285,6 +4286,55 @@ func (m *ItemMutation) ResetManufacturer() { delete(m.clearedFields, item.FieldManufacturer) } +// SetBarcode sets the "barcode" field. +func (m *ItemMutation) SetBarcode(s string) { + m.barcode = &s +} + +// Barcode returns the value of the "barcode" field in the mutation. +func (m *ItemMutation) Barcode() (r string, exists bool) { + v := m.barcode + if v == nil { + return + } + return *v, true +} + +// OldBarcode returns the old "barcode" field's value of the Item entity. +// If the Item object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ItemMutation) OldBarcode(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBarcode is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBarcode requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBarcode: %w", err) + } + return oldValue.Barcode, nil +} + +// ClearBarcode clears the value of the "barcode" field. +func (m *ItemMutation) ClearBarcode() { + m.barcode = nil + m.clearedFields[item.FieldBarcode] = struct{}{} +} + +// BarcodeCleared returns if the "barcode" field was cleared in this mutation. +func (m *ItemMutation) BarcodeCleared() bool { + _, ok := m.clearedFields[item.FieldBarcode] + return ok +} + +// ResetBarcode resets all changes to the "barcode" field. +func (m *ItemMutation) ResetBarcode() { + m.barcode = nil + delete(m.clearedFields, item.FieldBarcode) +} + // SetLifetimeWarranty sets the "lifetime_warranty" field. func (m *ItemMutation) SetLifetimeWarranty(b bool) { m.lifetime_warranty = &b @@ -5197,7 +5247,7 @@ func (m *ItemMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ItemMutation) Fields() []string { - fields := make([]string, 0, 24) + fields := make([]string, 0, 25) if m.created_at != nil { fields = append(fields, item.FieldCreatedAt) } @@ -5240,6 +5290,9 @@ func (m *ItemMutation) Fields() []string { if m.manufacturer != nil { fields = append(fields, item.FieldManufacturer) } + if m.barcode != nil { + fields = append(fields, item.FieldBarcode) + } if m.lifetime_warranty != nil { fields = append(fields, item.FieldLifetimeWarranty) } @@ -5306,6 +5359,8 @@ func (m *ItemMutation) Field(name string) (ent.Value, bool) { return m.ModelNumber() case item.FieldManufacturer: return m.Manufacturer() + case item.FieldBarcode: + return m.Barcode() case item.FieldLifetimeWarranty: return m.LifetimeWarranty() case item.FieldWarrantyExpires: @@ -5363,6 +5418,8 @@ func (m *ItemMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldModelNumber(ctx) case item.FieldManufacturer: return m.OldManufacturer(ctx) + case item.FieldBarcode: + return m.OldBarcode(ctx) case item.FieldLifetimeWarranty: return m.OldLifetimeWarranty(ctx) case item.FieldWarrantyExpires: @@ -5490,6 +5547,13 @@ func (m *ItemMutation) SetField(name string, value ent.Value) error { } m.SetManufacturer(v) return nil + case item.FieldBarcode: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBarcode(v) + return nil case item.FieldLifetimeWarranty: v, ok := value.(bool) if !ok { @@ -5659,6 +5723,9 @@ func (m *ItemMutation) ClearedFields() []string { if m.FieldCleared(item.FieldManufacturer) { fields = append(fields, item.FieldManufacturer) } + if m.FieldCleared(item.FieldBarcode) { + fields = append(fields, item.FieldBarcode) + } if m.FieldCleared(item.FieldWarrantyExpires) { fields = append(fields, item.FieldWarrantyExpires) } @@ -5712,6 +5779,9 @@ func (m *ItemMutation) ClearField(name string) error { case item.FieldManufacturer: m.ClearManufacturer() return nil + case item.FieldBarcode: + m.ClearBarcode() + return nil case item.FieldWarrantyExpires: m.ClearWarrantyExpires() return nil @@ -5783,6 +5853,9 @@ func (m *ItemMutation) ResetField(name string) error { case item.FieldManufacturer: m.ResetManufacturer() return nil + case item.FieldBarcode: + m.ResetBarcode() + return nil case item.FieldLifetimeWarranty: m.ResetLifetimeWarranty() return nil diff --git a/backend/internal/data/ent/runtime.go b/backend/internal/data/ent/runtime.go index 4d73e4556..c34838d8b 100644 --- a/backend/internal/data/ent/runtime.go +++ b/backend/internal/data/ent/runtime.go @@ -231,24 +231,28 @@ func init() { itemDescManufacturer := itemFields[9].Descriptor() // item.ManufacturerValidator is a validator for the "manufacturer" field. It is called by the builders before save. item.ManufacturerValidator = itemDescManufacturer.Validators[0].(func(string) error) + // itemDescBarcode is the schema descriptor for barcode field. + itemDescBarcode := itemFields[10].Descriptor() + // item.BarcodeValidator is a validator for the "barcode" field. It is called by the builders before save. + item.BarcodeValidator = itemDescBarcode.Validators[0].(func(string) error) // itemDescLifetimeWarranty is the schema descriptor for lifetime_warranty field. - itemDescLifetimeWarranty := itemFields[10].Descriptor() + itemDescLifetimeWarranty := itemFields[11].Descriptor() // item.DefaultLifetimeWarranty holds the default value on creation for the lifetime_warranty field. item.DefaultLifetimeWarranty = itemDescLifetimeWarranty.Default.(bool) // itemDescWarrantyDetails is the schema descriptor for warranty_details field. - itemDescWarrantyDetails := itemFields[12].Descriptor() + itemDescWarrantyDetails := itemFields[13].Descriptor() // item.WarrantyDetailsValidator is a validator for the "warranty_details" field. It is called by the builders before save. item.WarrantyDetailsValidator = itemDescWarrantyDetails.Validators[0].(func(string) error) // itemDescPurchasePrice is the schema descriptor for purchase_price field. - itemDescPurchasePrice := itemFields[15].Descriptor() + itemDescPurchasePrice := itemFields[16].Descriptor() // item.DefaultPurchasePrice holds the default value on creation for the purchase_price field. item.DefaultPurchasePrice = itemDescPurchasePrice.Default.(float64) // itemDescSoldPrice is the schema descriptor for sold_price field. - itemDescSoldPrice := itemFields[18].Descriptor() + itemDescSoldPrice := itemFields[19].Descriptor() // item.DefaultSoldPrice holds the default value on creation for the sold_price field. item.DefaultSoldPrice = itemDescSoldPrice.Default.(float64) // itemDescSoldNotes is the schema descriptor for sold_notes field. - itemDescSoldNotes := itemFields[19].Descriptor() + itemDescSoldNotes := itemFields[20].Descriptor() // item.SoldNotesValidator is a validator for the "sold_notes" field. It is called by the builders before save. item.SoldNotesValidator = itemDescSoldNotes.Validators[0].(func(string) error) // itemDescID is the schema descriptor for id field. diff --git a/backend/internal/data/ent/schema/item.go b/backend/internal/data/ent/schema/item.go index b689933e4..4580c60f3 100644 --- a/backend/internal/data/ent/schema/item.go +++ b/backend/internal/data/ent/schema/item.go @@ -29,6 +29,7 @@ func (Item) Indexes() []ent.Index { index.Fields("manufacturer"), index.Fields("model_number"), index.Fields("serial_number"), + index.Fields("barcode"), index.Fields("archived"), index.Fields("asset_id"), } @@ -65,6 +66,9 @@ func (Item) Fields() []ent.Field { field.String("manufacturer"). MaxLen(255). Optional(), + field.String("barcode"). + MaxLen(255). + Optional(), // ------------------------------------ // Item Warranty diff --git a/backend/internal/data/migrations/sqlite3/20250723163847_add_barcode.sql b/backend/internal/data/migrations/sqlite3/20250723163847_add_barcode.sql new file mode 100644 index 000000000..1faa8904b --- /dev/null +++ b/backend/internal/data/migrations/sqlite3/20250723163847_add_barcode.sql @@ -0,0 +1,4 @@ +-- +goose Up +ALTER TABLE items ADD COLUMN barcode TEXT; + + diff --git a/backend/internal/data/repo/repo_barcodes.go b/backend/internal/data/repo/repo_barcodes.go new file mode 100644 index 000000000..06d1bdfad --- /dev/null +++ b/backend/internal/data/repo/repo_barcodes.go @@ -0,0 +1,369 @@ +package repo + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/internal/sys/config" +) + +type ( + BarcodeRepository struct { + } + + BarcodeProduct struct { + SearchEngineName string `json:"search_engine_name"` + SearchEngineURL string `json:"search_engine_url"` + SearchEngineProductURL string `json:"search_engine_product_url"` + + // Extras + Country string `json:"notes"` + + ImageURL string `json:"imageURL"` + ImageBase64 string `json:"imageBase64"` + + Item ItemCreate `json:"item"` + } + + ProductDatabaseFunc func(cfg config.BarcodeAPIConf, barcode string, mockedURL string) ([]BarcodeProduct, error) + + ProductDatabaseImpl struct { + url string + name string + call ProductDatabaseFunc + } + + UPCITEMDBResponse struct { + Code string `json:"code"` + Total int `json:"total"` + Offset int `json:"offset"` + Items []struct { + Ean string `json:"ean"` + Title string `json:"title"` + Description string `json:"description"` + Upc string `json:"upc"` + Brand string `json:"brand"` + Model string `json:"model"` + Color string `json:"color"` + Size string `json:"size"` + Dimension string `json:"dimension"` + Weight string `json:"weight"` + Category string `json:"category"` + LowestRecordedPrice float64 `json:"lowest_recorded_price"` + HighestRecordedPrice float64 `json:"highest_recorded_price"` + Images []string `json:"images"` + Offers []struct { + Merchant string `json:"merchant"` + Domain string `json:"domain"` + Title string `json:"title"` + Currency string `json:"currency"` + ListPrice string `json:"list_price"` + Price float64 `json:"price"` + Shipping string `json:"shipping"` + Condition string `json:"condition"` + Availability string `json:"availability"` + Link string `json:"link"` + UpdatedT int `json:"updated_t"` + } `json:"offers"` + Asin string `json:"asin"` + Elid string `json:"elid"` + } `json:"items"` + } + + BARCODESPIDER_COMResponse struct { + ItemResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + } `json:"item_response"` + ItemAttributes struct { + Title string `json:"title"` + Upc string `json:"upc"` + Ean string `json:"ean"` + ParentCategory string `json:"parent_category"` + Category string `json:"category"` + Brand string `json:"brand"` + Model string `json:"model"` + Mpn string `json:"mpn"` + Manufacturer string `json:"manufacturer"` + Publisher string `json:"publisher"` + Asin string `json:"asin"` + Color string `json:"color"` + Size string `json:"size"` + Weight string `json:"weight"` + Image string `json:"image"` + IsAdult string `json:"is_adult"` + Description string `json:"description"` + } `json:"item_attributes"` + Stores []struct { + StoreName string `json:"store_name"` + Title string `json:"title"` + Image string `json:"image"` + Price string `json:"price"` + Currency string `json:"currency"` + Link string `json:"link"` + Updated string `json:"updated"` + } `json:"Stores"` + } +) + +const FOREIGN_API_CALL_TIMEOUT_SEC = 10 + +func (r *BarcodeRepository) UPCItemDB_Search(_ config.BarcodeAPIConf, iBarcode string, mockedURL string) ([]BarcodeProduct, error) { + + apiUrl := "https://api.upcitemdb.com" + + if mockedURL != "" { + apiUrl = mockedURL + } + + client := &http.Client{Timeout: FOREIGN_API_CALL_TIMEOUT_SEC * time.Second} + resp, err := client.Get(apiUrl + "/prod/trial/lookup?upc=" + iBarcode) + if err != nil { + return nil, err + } + + defer func() { + err = errors.Join(err, resp.Body.Close()) + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) + } + + // We Read the response body on the line below. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Uncomment the following string for debug + // sb := string(body) + // log.Debug().Msg("Response: " + sb) + + var result UPCITEMDBResponse + if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer + log.Error().Msg("Can not unmarshal JSON") + } + + var res []BarcodeProduct + + for _, it := range result.Items { + var p BarcodeProduct + p.Item.Barcode = iBarcode + p.SearchEngineProductURL = "https://www.upcitemdb.com/upc/" + iBarcode + + p.Item.Description = it.Description + p.Item.Name = it.Title + p.Item.Manufacturer = it.Brand + p.Item.ModelNumber = it.Model + if len(it.Images) != 0 { + p.ImageURL = it.Images[0] + } + + res = append(res, p) + } + + return res, nil +} + +func (r *BarcodeRepository) BarcodeSpider_Search(conf config.BarcodeAPIConf, iBarcode string, mockedURL string) ([]BarcodeProduct, error) { + if len(conf.TokenBarcodespider) == 0 && mockedURL == "" { + return nil, errors.New("no api token configured for barcodespider. " + + "Please define the api token in environment variable HBOX_BARCODE_TOKEN_BARCODESPIDER") + } + + apiUrl := "https://api.barcodespider.com" + + if mockedURL != "" { + apiUrl = mockedURL + } + + req, err := http.NewRequest( + "GET", apiUrl+"/v1/lookup?upc="+url.QueryEscape(iBarcode), nil) + + if err != nil { + return nil, err + } + + req.Header.Add("token", conf.TokenBarcodespider) + + client := &http.Client{Timeout: FOREIGN_API_CALL_TIMEOUT_SEC * time.Second} + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + // defer the call to Body.Close(). We also check the error code, and merge + // it with the other error in this code to avoid error overiding. + defer func() { + err = errors.Join(err, resp.Body.Close()) + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("barcodespider API returned status code: %d", resp.StatusCode) + } + + // We Read the response body on the line below. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, nil + } + + // Uncomment the following string for debug + // sb := string(body) + // log.Debug().Msg("Response: " + sb) + + var result BARCODESPIDER_COMResponse + if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer + log.Error().Msg("Can not unmarshal JSON") + // NOTE: unmarshal could fail but the decoded data are still exploitable. + // This is why we do not consider this case as a fatal error. + } + + if result.ItemResponse.Code != 200 { + return nil, nil + } + + // TODO: check 200 code on HTTP response. + var p BarcodeProduct + p.Item.Barcode = iBarcode + p.SearchEngineProductURL = "https://amp.barcodespider.com/" + iBarcode + p.Item.Name = result.ItemAttributes.Title + p.Item.Description = result.ItemAttributes.Description + p.Item.Manufacturer = result.ItemAttributes.Brand + p.Item.ModelNumber = result.ItemAttributes.Model + p.ImageURL = result.ItemAttributes.Image + + var res []BarcodeProduct + res = append(res, p) + + return res, nil +} + +func (r *BarcodeRepository) UpdateProductWithImage(iProduct *BarcodeProduct) { + p := iProduct + + if len(p.ImageURL) == 0 { + return + } + + // Validate URL is HTTPS + u, err := url.Parse(p.ImageURL) + if err != nil || u.Scheme != "https" { + log.Warn().Msg("Skipping non-HTTPS image URL: " + p.ImageURL) + return + } + + client := &http.Client{Timeout: FOREIGN_API_CALL_TIMEOUT_SEC * time.Second} + res, err := client.Get(p.ImageURL) + if err != nil { + log.Warn().Msg("Cannot fetch image for URL: " + p.ImageURL + ": " + err.Error()) + } + + defer func() { + err = errors.Join(err, res.Body.Close()) + }() + + // Validate response + if res.StatusCode != http.StatusOK { + return + } + + // Check content type + contentType := res.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + return + } + + // Limit image size to 8MB + limitedReader := io.LimitReader(res.Body, 8*1024*1024) + + // Read data of image + bytes, err := io.ReadAll(limitedReader) + if err != nil { + log.Warn().Msg(err.Error()) + return + } + + // Convert to Base64 + var base64Encoding string + + // Determine the content type of the image file + mimeType := http.DetectContentType(bytes) + + // Prepend the appropriate URI scheme header depending + // on the MIME type + switch mimeType { + case "image/jpeg": + base64Encoding += "data:image/jpeg;base64," + case "image/png": + base64Encoding += "data:image/png;base64," + default: + return + } + + // Append the base64 encoded output + base64Encoding += base64.StdEncoding.EncodeToString(bytes) + + p.ImageBase64 = base64Encoding +} + +func (r *BarcodeRepository) RetrieveProductsFromBarcode(conf config.BarcodeAPIConf, iBarcode string) ([]BarcodeProduct, error) { + log.Info().Msg("Processing barcode lookup request on: " + iBarcode) + + // For further implementer: we try to not use non-free databases + // - www.ean-search.org/: not free + // - barcodelookup.com/: trial with 50 items search / months. Need phone number for registration. + + remoteAPIs := []ProductDatabaseImpl{ + { + url: "https://upcitemdb.com", + name: "UPCDBItem", + call: r.UPCItemDB_Search, // Assign function 1 + }, + { + url: "https://barcodespider.com", + name: "BarcodeSpider", + call: r.BarcodeSpider_Search, // Assign function 2 + }, + } + + var products []BarcodeProduct + + // Call external APIs + for _, api := range remoteAPIs { + ps, err := api.call(conf, iBarcode, "") + if err != nil { + log.Error().Msg("Can not retrieve product from " + api.name + err.Error()) + } + + for idx := range ps { + // Update each product with API information + p := &ps[idx] + p.SearchEngineName = api.name + p.SearchEngineURL = api.url + + // Fetch image of each product on the Internet + r.UpdateProductWithImage(p) + } + + // Merge found products. + products = append(products, ps...) + } + + return products, nil +} diff --git a/backend/internal/data/repo/repo_barcodes_test.go b/backend/internal/data/repo/repo_barcodes_test.go new file mode 100644 index 000000000..6082061f2 --- /dev/null +++ b/backend/internal/data/repo/repo_barcodes_test.go @@ -0,0 +1,129 @@ +package repo + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/internal/sys/config" +) + +func checkError(t *testing.T, err error) { + if err != nil { + t.Helper() // mark as helper so failure line points to caller + t.Fatalf("unexpected error: %v", err) + } +} + +func readJSONResponseSamplesFromDisk(t *testing.T, subfolder string) map[string]string { + entries, err := os.ReadDir(subfolder) + if err != nil { + t.Fatal(err) + } + + res := make(map[string]string) + + for _, e := range entries { + path := filepath.Join(subfolder, e.Name()) + + data, err := os.ReadFile(path) + + if err != nil { + t.Fatal(err) + } + + res[e.Name()] = string(data[:]) + } + + return res +} + +type ( + ServerHandlerFunction func(w http.ResponseWriter, r *http.Request, productMap map[string]string) + + ProductDatabaseTest struct { + api_name string + barcode_handler ProductDatabaseFunc + products map[string]string + mock_server *httptest.Server + } +) + +func PrepareTest(t *testing.T, apiName string, barcodeHandler ProductDatabaseFunc, folderContainingJsonsSamples string, handler ServerHandlerFunction) ProductDatabaseTest { + var p ProductDatabaseTest + p.api_name = apiName + p.products = readJSONResponseSamplesFromDisk(t, folderContainingJsonsSamples) + p.barcode_handler = barcodeHandler + + p.mock_server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler(w, r, p.products) + })) + + return p +} + +func _TestBarcode_UPCDBItemHandler(w http.ResponseWriter, r *http.Request, productMap map[string]string) { + + upc := r.URL.Query().Get("upc") + log.Info().Msg(upc) + + t := productMap[upc+".json"] + + // Cover the case where no product is found. + if t == "" { + t = productMap["empty.json"] + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(t)) +} + +func _TestBarcode_BarcodeSpiderHandler(w http.ResponseWriter, r *http.Request, productMap map[string]string) { + + upc := r.URL.Query().Get("upc") + log.Info().Msg(upc) + + t := productMap[upc+".json"] + + // Cover the case where no product is found. + if t == "" { + t = productMap["empty.json"] + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(t)) +} + +func TestBarcode_SearchProducts(t *testing.T) { + + gtins := []string{ + "855800001203", // Das Keyboard + "855800001869", // Das Keyboard + "5035048748428", // Dewalt multitool + "885911209809", // Dewalt drill + } + + // For each database, we create a "mock" server to simulate the responses from the API. + productSearchTests := []ProductDatabaseTest{ + PrepareTest(t, "UPCDBItem", tRepos.Barcode.UPCItemDB_Search, "testdata/upcitemdb", _TestBarcode_UPCDBItemHandler), + PrepareTest(t, "BarcodeSpider", tRepos.Barcode.BarcodeSpider_Search, "testdata/barcodespider", _TestBarcode_BarcodeSpiderHandler), + } + + for _, api := range productSearchTests { + + log.Info().Msg("Testing " + api.api_name) + + for _, bc := range gtins { + products, err := api.barcode_handler(config.BarcodeAPIConf{}, bc, api.mock_server.URL) + + checkError(t, err) + + for _, p := range products { + log.Info().Msg("Found: " + p.Item.Name) + } + } + } +} diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index 4fb50255b..c82de48a3 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -73,6 +73,10 @@ type ( Description string `json:"description" validate:"max=1000"` AssetID AssetID `json:"-"` + ModelNumber string `json:"modelNumber"` + Manufacturer string `json:"manufacturer"` + Barcode string `json:"barcode"` + // Edges LocationID uuid.UUID `json:"locationId"` LabelIDs []uuid.UUID `json:"labelIds"` @@ -97,6 +101,7 @@ type ( SerialNumber string `json:"serialNumber"` ModelNumber string `json:"modelNumber"` Manufacturer string `json:"manufacturer"` + Barcode string `json:"barcode"` // Warranty LifetimeWarranty bool `json:"lifetimeWarranty"` @@ -160,6 +165,7 @@ type ( SerialNumber string `json:"serialNumber"` ModelNumber string `json:"modelNumber"` Manufacturer string `json:"manufacturer"` + Barcode string `json:"barcode"` // Warranty LifetimeWarranty bool `json:"lifetimeWarranty"` @@ -292,6 +298,7 @@ func mapItemOut(item *ent.Item) ItemOut { SerialNumber: item.SerialNumber, ModelNumber: item.ModelNumber, Manufacturer: item.Manufacturer, + Barcode: item.Barcode, // Purchase PurchaseTime: types.DateFromTime(item.PurchaseTime), @@ -380,6 +387,7 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite item.SerialNumberContainsFold(q.Search), item.ModelNumberContainsFold(q.Search), item.ManufacturerContainsFold(q.Search), + item.BarcodeContainsFold(q.Search), item.NotesContainsFold(q.Search), // Accent-insensitive search using custom predicates ent.ItemNameAccentInsensitiveContains(q.Search), @@ -610,7 +618,10 @@ func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCr SetDescription(data.Description). SetGroupID(gid). SetLocationID(data.LocationID). - SetAssetID(int(data.AssetID)) + SetAssetID(int(data.AssetID)). + SetManufacturer(data.Manufacturer). + SetModelNumber(data.ModelNumber). + SetBarcode(data.Barcode) if data.ParentID != uuid.Nil { q.SetParentID(data.ParentID) @@ -662,6 +673,7 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data SetSerialNumber(data.SerialNumber). SetModelNumber(data.ModelNumber). SetManufacturer(data.Manufacturer). + SetBarcode(data.Barcode). SetArchived(data.Archived). SetPurchaseTime(data.PurchaseTime.Time()). SetPurchaseFrom(data.PurchaseFrom). @@ -1055,6 +1067,7 @@ func (e *ItemsRepository) Duplicate(ctx context.Context, gid, id uuid.UUID, opti SetDescription(originalItem.Description). SetQuantity(originalItem.Quantity). SetLocationID(originalItem.Location.ID). + SetBarcode(originalItem.Barcode). SetGroupID(gid). SetAssetID(int(nextAssetID)). SetSerialNumber(originalItem.SerialNumber). diff --git a/backend/internal/data/repo/repo_items_search_test.go b/backend/internal/data/repo/repo_items_search_test.go index cb7accf3b..76019a618 100644 --- a/backend/internal/data/repo/repo_items_search_test.go +++ b/backend/internal/data/repo/repo_items_search_test.go @@ -3,18 +3,18 @@ package repo import ( "testing" - "github.com/sysadminsmedia/homebox/backend/pkgs/textutils" "github.com/stretchr/testify/assert" + "github.com/sysadminsmedia/homebox/backend/pkgs/textutils" ) func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) { // Test cases for accent-insensitive search testCases := []struct { - name string - itemName string - searchQuery string - shouldMatch bool - description string + name string + itemName string + searchQuery string + shouldMatch bool + description string }{ { name: "Spanish accented item, search without accents", @@ -155,25 +155,25 @@ func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Test the normalization logic used in the repository normalizedSearch := textutils.NormalizeSearchQuery(tc.searchQuery) - + // This simulates what happens in the repository // The original search would find exact matches (case-insensitive) // The normalized search would find accent-insensitive matches - + // Test that our normalization works as expected if tc.shouldMatch { // If it should match, then either the original query should match // or the normalized query should match when applied to the stored data - assert.NotEqual(t, "", normalizedSearch, "Normalized search should not be empty") - + assert.NotEmpty(t, normalizedSearch, "Normalized search should not be empty") + // The key insight is that we're searching with both the original and normalized queries // So "electrónica" will be found when searching for "electronica" because: // 1. Original search: "electronica" doesn't match "electrónica" // 2. Normalized search: "electronica" matches the normalized version - t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')", + t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')", tc.description, tc.itemName, tc.searchQuery, normalizedSearch) } else { - t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')", + t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')", tc.description, tc.itemName, tc.searchQuery, normalizedSearch) } }) diff --git a/backend/internal/data/repo/repo_product_search.go b/backend/internal/data/repo/repo_product_search.go deleted file mode 100644 index 3e0c6b158..000000000 --- a/backend/internal/data/repo/repo_product_search.go +++ /dev/null @@ -1,18 +0,0 @@ -package repo - -type BarcodeProduct struct { - SearchEngineName string `json:"search_engine_name"` - - // Identifications - ModelNumber string `json:"modelNumber"` - Manufacturer string `json:"manufacturer"` - - // Extras - Country string `json:"notes"` - Barcode string `json:"barcode"` - - ImageURL string `json:"imageURL"` - ImageBase64 string `json:"imageBase64"` - - Item ItemCreate `json:"item"` -} diff --git a/backend/internal/data/repo/repos_all.go b/backend/internal/data/repo/repos_all.go index d75cd03a3..d41c92ccb 100644 --- a/backend/internal/data/repo/repos_all.go +++ b/backend/internal/data/repo/repos_all.go @@ -18,6 +18,7 @@ type AllRepos struct { Attachments *AttachmentRepo MaintEntry *MaintenanceEntryRepository Notifiers *NotifierRepository + Barcode *BarcodeRepository } func New(db *ent.Client, bus *eventbus.EventBus, storage config.Storage, pubSubConn string, thumbnail config.Thumbnail) *AllRepos { @@ -31,5 +32,6 @@ func New(db *ent.Client, bus *eventbus.EventBus, storage config.Storage, pubSubC Attachments: &AttachmentRepo{db, storage, pubSubConn, thumbnail}, MaintEntry: &MaintenanceEntryRepository{db}, Notifiers: NewNotifierRepository(db), + Barcode: &BarcodeRepository{}, } } diff --git a/backend/internal/data/repo/testdata/barcodespider/855800001203.json b/backend/internal/data/repo/testdata/barcodespider/855800001203.json new file mode 100644 index 000000000..9eff5cfd3 --- /dev/null +++ b/backend/internal/data/repo/testdata/barcodespider/855800001203.json @@ -0,0 +1 @@ +{"item_response":{"code":200,"status":"OK","message":"Data returned"},"item_attributes":{"title":"Das Keyboard Model S Professional for Mac Cherry MX Blue Mechanical Keyboard - Clicky","upc":"855800001203","ean":"0855800001203","parent_category":"Electronics","category":"Desktop Computers","brand":"Das Keyboard","model":"DASK3PROMS1MACCLI","mpn":"DASK3PROMS1MACCLI","manufacturer":"Das Keyboard","publisher":"Das Keyboard","asin":"B003ZG9T62","color":"Clicky - Cherry MX Blue Switch","size":"","weight":"300 hundredths-pounds","image":"https://images.barcodespider.com/upcimage/855800001203.jpg","is_adult":"0","description":""},"Stores":[{"store_name":"Amazon US","title":"Das Keyboard Model S Professional for Mac Cherry MX Blue Mechanical Keyboard - Clicky","image":"https://images-na.ssl-images-amazon.com/images/I/41fG3Ou65wL.jpg","price":"119.00","currency":"USD","link":"https://www.barcodespider.com/getstore/ODU1ODAwMDAxMjAzfEFtYXpvbiBVUw,,","updated":"2019-07-10 14:41:50"}]} \ No newline at end of file diff --git a/backend/internal/data/repo/testdata/barcodespider/empty.json b/backend/internal/data/repo/testdata/barcodespider/empty.json new file mode 100644 index 000000000..876857bfd --- /dev/null +++ b/backend/internal/data/repo/testdata/barcodespider/empty.json @@ -0,0 +1 @@ +{"item_response":{"code":404,"status":"NOT_FOUND","message":"The requested object does not exist"}} \ No newline at end of file diff --git a/backend/internal/data/repo/testdata/upcitemdb/855800001869.json b/backend/internal/data/repo/testdata/upcitemdb/855800001869.json new file mode 100644 index 000000000..f084da187 --- /dev/null +++ b/backend/internal/data/repo/testdata/upcitemdb/855800001869.json @@ -0,0 +1 @@ +{"code":"OK","total":1,"offset":0,"items":[{"ean":"0855800001869","title":"Das Keyboard 4 Ultimate Soft Tactile Usb Qwerty International Era Black -","description":"","upc":"855800001869","elid":"292890881066","brand":"DAS Keyboard","model":"","color":"","size":"","dimension":"","weight":"","category":"Electronics > Electronics Accessories > Computer Components > Input Devices > Keyboards","lowest_recorded_price":150.86,"highest_recorded_price":326,"images":[],"offers":[]}]} \ No newline at end of file diff --git a/backend/internal/data/repo/testdata/upcitemdb/885911209809.json b/backend/internal/data/repo/testdata/upcitemdb/885911209809.json new file mode 100644 index 000000000..c82061326 --- /dev/null +++ b/backend/internal/data/repo/testdata/upcitemdb/885911209809.json @@ -0,0 +1 @@ +{"code":"OK","total":1,"offset":0,"items":[{"ean":"0885911209809","title":"DEWALT 20-Volt Max Lithium Ion (Li-ion) 1/2-in Cordless Drill","description":"High-speed transmission delivers 2 speeds (0-600 and 0-2,000 rpm) for a range of fastening and drilling applications; 1/2-in ratcheting chuck provides superior bit grip; 15 clutch settings allow you to adjust the torque to match the task at hand; Compact, lightweight design fits into tight areas; Ergonomic handle delivers comfort and control; Belt hook allows you to keep the drill close at hand; Includes (1) DCD780 drill/driver, (2) 20V MAX 1.5Ah lithium ion battery paclks, charger, belt hook and contracto...","upc":"885911209809","brand":"DEWALT","model":"DCD780C2","color":"Black","size":"3.4 lbs","dimension":"16.2 X 9.9 X 4.5 inches","weight":"18.6 Pounds","category":"Hardware > Tools > Drills > Handheld Power Drills","lowest_recorded_price":7.95,"highest_recorded_price":900,"images":["https://mobileimages.lowes.com/product/converted/885911/885911209809lg.jpg","http://scene7.samsclub.com/is/image/samsclub/0088591120980_A?$img_size_211x208$","https://www.build.com/imagebase/resized/x800/Dewaltimages/dewalt_dcd780c2_20.jpg","http://images.jcwstatics.com/is/image/Autos/dwtdcd780c2_is?$JCW_List$","http://images.qvc.com/is/image/h/33/h364733.001?wid=225&op_sharpen=1","http://c.shld.net/rpx/i/s/i/spin/image/spin_prod_675446101","https://www.officedepot.com/pictures/us/od/sk/lg/815225_sk_lg.jpg","https://images.thdstatic.com/productImages/330ba505-3806-4dc4-b17a-2e897032f54a/svn/dewalt-power-drills-dcd780c2-64_1000.jpg","https://i5.walmartimages.com/asr/b9f20c89-cf44-409f-ae5e-5ebba942e05a_1.226c55313dc6b9703a3939d5bbb88833.jpeg?odnHeight=450&odnWidth=450&odnBg=ffffff","https://s7d9.scene7.com/is/image/JCPenney/DP0601201617083431M?wid=800&hei=800&op_sharpen=1"],"offers":[{"merchant":"CPO","domain":"cpooutlets.com","title":"Dewalt DCD780C2 20V MAX Cordless Lithium-Ion 1/2 in. Compact Drill Driver Kit","currency":"","list_price":"","price":199,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=z2x25313x2z2a4&tid=1&seq=1757621955&plt=4844bad4adb7dbd00df9fc8eb6eb1579","updated_t":1541799684},{"merchant":"Lowe's","domain":"lowes.com","title":"DEWALT 20-Volt Max Lithium Ion (Li-ion) 1/2-in Cordless Drill","currency":"","list_price":"","price":159,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=w2t2y2z2031364a4y2&tid=1&seq=1757621955&plt=091cf6a8e723538c10deb6039796e36d","updated_t":1535258437},{"merchant":"Sam's Club","domain":"samsclub.com","title":"Dewalt 20-Volt Max Lithium-Ion Cordless Compact Drill/Driver","currency":"","list_price":"","price":159.98,"shipping":"Free Shipping","condition":"New","availability":"Out of Stock","link":"https://www.upcitemdb.com/norob/alink/?id=u2u243w20313d494y2&tid=1&seq=1757621955&plt=a61d11370b886b696038198de1cdf8e0","updated_t":1465055558},{"merchant":"Build.com","domain":"build.com","title":"DeWalt DCD780C2 N/A 20 Volt MAX Lithium Ion Compact Drill Kit","currency":"","list_price":"","price":242.34,"shipping":"Free Shipping","condition":"New","availability":"Out of Stock","link":"https://www.upcitemdb.com/norob/alink/?id=w2o203z2v223c4b4&tid=1&seq=1757621955&plt=704fa7cfdaffc7b1d288fa5abf58dece","updated_t":1532757093},{"merchant":"Factory Outlet Store","domain":"factoryoutletstore.com","title":"DeWALT DCD780C2B DEWALT DCD780C2 20-Volt Max Li-Ion Compact 1.5 Ah Dri","currency":"","list_price":"","price":211.95,"shipping":"0","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=u2q233031313b444s2&tid=1&seq=1757621955&plt=d392247101919c16785988d114521f04","updated_t":1479846044},{"merchant":"JC Whitney","domain":"jcwhitney.com","title":"0 Cordless Drill","currency":"","list_price":"","price":199,"shipping":"0","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2o2x2v2z243b4d4q2&tid=1&seq=1757621955&plt=07398f793174ded96fe50b4b6852a28b","updated_t":1475188753},{"merchant":"Shoplet.com","domain":"shoplet.com","title":"20V COMPACT DRILL DRVR KT","currency":"","list_price":"","price":349.1,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=u2u243w2w2237484&tid=1&seq=1757621955&plt=c4eee5127b2caa2aefe75fa46a247baa","updated_t":1570107715},{"merchant":"Newegg.com","domain":"newegg.com","title":"DeWalt DCD780C2 20V 20-Volt Max Li-Ion Compact 1.5 Ah Drill Driver Kit","currency":"","list_price":"","price":189.99,"shipping":"0.01","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=u2p223u2x2036484u2&tid=1&seq=1757621955&plt=783a93df8f759ba1c414aa84b0f9ba22","updated_t":1648607389},{"merchant":"UnbeatableSale.com","domain":"unbeatablesale.com","title":"Power Tools DCD780C2 20 Volt Max Lithium Ion Compact Drill-Driver Kit","currency":"","list_price":294.15,"price":217.89,"shipping":"US:::14.96 USD","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2o243030333c444&tid=1&seq=1757621955&plt=7ed0ef716dae79eeb8d6f73f78d41eec","updated_t":1692341867},{"merchant":"Homedepot Canada","domain":"homedepot.ca","title":"DEWALT 20V MAX Lithium-Ion Cordless Compact Drill/Driver with (2) 1.5Ah Batteries, Charger and Bag","currency":"CAD","list_price":"","price":289,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=13q223t24353c494&tid=1&seq=1757621955&plt=67f10c0600b39a9acff286bbf65d843f","updated_t":1673671757},{"merchant":"DollarDays","domain":"dollardays.com","title":"Dewalt 1/2\" 20V Compact Lithium Drill","currency":"","list_price":"","price":308.07,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2q2x2z233z29474&tid=1&seq=1757621955&plt=8b3673711b983819e70857e29cf11164","updated_t":1435972506},{"merchant":"PCM","domain":"pcm.com","title":"Stanley Black & Decker DCD780C2 DW 20V MAX LITH ION DRILL KIT","currency":"","list_price":"","price":211.99,"shipping":"7.99","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2p2430333x2b4b4w2&tid=1&seq=1757621955&plt=6040d7e64a122df7d474351bd502010d","updated_t":1534761702},{"merchant":"QVC.com","domain":"qvc.com","title":"DeWalt DCD780C2 20-Volt Li-ion Compact Drill Driver Kit","currency":"","list_price":"","price":316.8,"shipping":"12.22","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2q2z2u24333b4b4&tid=1&seq=1757621955&plt=6e0521313c5e755dbce8404ad86007b1","updated_t":1481116281},{"merchant":"Sears","domain":"sears.com","title":"20 V MAX* Lithium Ion Compact Drill / Driver Kit (1.5 Ah)","currency":"","list_price":"","price":199.99,"shipping":"Free Shipping","condition":"New","availability":"Out of Stock","link":"https://www.upcitemdb.com/norob/alink/?id=v2q20313v203e474&tid=1&seq=1757621955&plt=c92d60d5f3b52bf2ce0e860dafb31e76","updated_t":1498909559},{"merchant":"MacMall","domain":"macmall.com","title":"Stanley Black & Decker DCD780C2 DW 20V MAX LITH ION DRILL KIT","currency":"","list_price":"","price":211.99,"shipping":"0","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2p243y233z2b4a4y2&tid=1&seq=1757621955&plt=d669e38bc8cfee69da94db2fe10f1b0a","updated_t":1535698782},{"merchant":"Office Depot","domain":"officedepot.com","title":"Dewalt Compact Drill/Driver Kit","currency":"","list_price":"","price":236.29,"shipping":"Free Shipping","condition":"New","availability":"Out of Stock","link":"https://www.upcitemdb.com/norob/alink/?id=u2s253z223139484x2&tid=1&seq=1757621955&plt=42f76c13a570043172d6cf27028a8984","updated_t":1534847083},{"merchant":"Mwave","domain":"mwave.com","title":"Dewalt Dcd780c2 20-Volt Li-Ion Compact Drill Driver Kit","currency":"","list_price":206.99,"price":196.39,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2x223u2x203b464&tid=1&seq=1757621955&plt=14099a466a001da79fc011d21ffe2a9f","updated_t":1478041356},{"merchant":"Home Depot","domain":"homedepot.com","title":"20V MAX Cordless Compact 1/2 in. Drill/Drill Driver with (2) 20V 1.3Ah Batteries, Charger and Bag","currency":"","list_price":"","price":199,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=w2t243u2z2x2f494&tid=1&seq=1757621955&plt=cfa702ad3f56c60c8ff37b1b382a95fc","updated_t":1733030941},{"merchant":"Rakuten(Buy.com)","domain":"rakuten.com","title":"Dewalt DCD780C2 20-Volt 1.5Ah 15-Position Cordless Compact Drill/Driver Kit","currency":"","list_price":"","price":179,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=w2v253y2v233f444&tid=1&seq=1757621955&plt=5ed6f145fde65408cead989281d7a59c","updated_t":1598284924},{"merchant":"pcRUSH.com","domain":"pcrush.com","title":"Compact Drill/Driver Kit","currency":"","list_price":344.14,"price":195.7,"shipping":"13.89","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=x2q263x2v26384c4&tid=1&seq=1757621955&plt=e57d7674801644f26a8bbc674ee0451e","updated_t":1499806929},{"merchant":"Wal-Mart.com","domain":"walmart.com","title":"20-Volt Li-Ion Compact Drill/Driver Kit","currency":"","list_price":"","price":224.82,"shipping":"Free Shipping","condition":"New","availability":"Out of Stock","link":"https://www.upcitemdb.com/norob/alink/?id=13t2y213x2037444&tid=1&seq=1757621955&plt=e636aa1b1a7249062aef7df3e253fbdd","updated_t":1515290868},{"merchant":"TigerDirect","domain":"tigerdirect.com","title":"20-VOLT LI-ION COMPACT DRILL/DRIVER KIT","currency":"","list_price":416.69,"price":236.49,"shipping":"7.63","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=y2t2x2t203137444&tid=1&seq=1757621955&plt=9a3400ab1e4efb0850b036788b35be73","updated_t":1428167163},{"merchant":"JCPenney","domain":"jcpenney.com","title":"DeWALT 20-Volt Lithium Ion Drill/Driver","currency":"","list_price":"","price":245.09,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=u2v243u22323e464u2&tid=1&seq=1757621955&plt=739464737d01dce069630e0cd31bc3a1","updated_t":1522812985},{"merchant":"Ace Hardware","domain":"acehardware.com","title":"DeWalt 1/2in 20V Lithium Ion Compact Drill / Driver Kit (DCD780C2)","currency":"","list_price":"","price":199.99,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=13v2032313136494&tid=1&seq=1757621955&plt=d30819d7a9e2dc354d0b0001fbd73dd2","updated_t":1569816019},{"merchant":"","domain":"","title":"Dewalt DCD780C2 20V MAX 1.5 Ah Cordless Lithium-Ion 1/2 in. Compact Drill Driver Kit","currency":"","list_price":"","price":179,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=u2u203z2x243d4c4z2&tid=1&seq=1757621955&plt=b812176a436ebb40ebb504e21e5a85e8","updated_t":1459771908},{"merchant":"True Value Hardware","domain":"truevalue.com","title":"20-Volt Compact Cordless Drill / Driver Kit, 1/2-In., 2 Lithium-Ion Batteries","currency":"","list_price":"","price":179,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2o243v2333394c4r2&tid=1&seq=1757621955&plt=9037f24693fc193ab75fedff1fc7d759","updated_t":1589548548},{"merchant":"Newegg Canada","domain":"newegg.ca","title":"DCD780C2 20V MAX 1.5 Ah Cordless Lithium-Ion 1/2 in. Compact Drill Driver Kit","currency":"CAD","list_price":"","price":284.69,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2o24323z22394d4r2&tid=1&seq=1757621955&plt=9d06e1c5a77e838e6c5f26464e8923c2","updated_t":1550908433},{"merchant":"Hardware World","domain":"hardwareworld.com","title":"DeWalt DCD780C2 20v Drill Driver Kit","currency":"","list_price":229.99,"price":221.29,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2q243x22343f454w2&tid=1&seq=1757621955&plt=2a5afb0391119acd669d4dca716b283e","updated_t":1757409281},{"merchant":"Peazz","domain":"peazz.com","title":"DEWALT DCD780C2 20-Volt Li-Ion Compact Drill/Driver Kit","currency":"","list_price":"","price":235.23,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2q253t22353c464r2&tid=1&seq=1757621955&plt=0b97ced523799333b897c800ebcedfa0","updated_t":1757580912},{"merchant":"Pricefalls.com","domain":"pricefalls.com","title":"20v Li-Ion Dril Driver","currency":"","list_price":"","price":203.38,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=v2q253134363d4b4q2&tid=1&seq=1757621955&plt=2293d01288db74456495866fbd7b3a39","updated_t":1484937299},{"merchant":"Jet.com","domain":"jet.com","title":"DeWalt DCD780C2 20-Volt MAX Lithium-Ion Cordless 1/2-Inch Compact Drill-Driver Kit","currency":"","list_price":"","price":185,"shipping":"","condition":"New","availability":"","link":"https://www.upcitemdb.com/norob/alink/?id=w2s243t243x2d444y2&tid=1&seq=1757621955&plt=9915f257317d8536175bbd4d9631b3e8","updated_t":1542846127}],"asin":"B0052MIHMO","elid":"124124881339"}]} \ No newline at end of file diff --git a/backend/internal/data/repo/testdata/upcitemdb/empty.json b/backend/internal/data/repo/testdata/upcitemdb/empty.json new file mode 100644 index 000000000..d59c8ad1a --- /dev/null +++ b/backend/internal/data/repo/testdata/upcitemdb/empty.json @@ -0,0 +1 @@ +{"code":"OK","total":0,"offset":0,"items":[]} \ No newline at end of file diff --git a/backend/pkgs/textutils/normalize.go b/backend/pkgs/textutils/normalize.go index 4e86235df..f484f4d69 100644 --- a/backend/pkgs/textutils/normalize.go +++ b/backend/pkgs/textutils/normalize.go @@ -22,13 +22,13 @@ func RemoveAccents(text string) string { // 2. Removes diacritical marks (combining characters) // 3. Normalizes back to NFC (canonical composition) t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) - + result, _, err := transform.String(t, text) if err != nil { // If transformation fails, return the original text return text } - + return result } diff --git a/docs/en/api/openapi-2.0.json b/docs/en/api/openapi-2.0.json index c62359b92..4e0d2095d 100644 --- a/docs/en/api/openapi-2.0.json +++ b/docs/en/api/openapi-2.0.json @@ -2558,6 +2558,10 @@ "description": "AssetID holds the value of the \"asset_id\" field.", "type": "integer" }, + "barcode": { + "description": "Barcode holds the value of the \"barcode\" field.", + "type": "string" + }, "created_at": { "description": "CreatedAt holds the value of the \"created_at\" field.", "type": "string" @@ -3141,9 +3145,6 @@ "repo.BarcodeProduct": { "type": "object", "properties": { - "barcode": { - "type": "string" - }, "imageBase64": { "type": "string" }, @@ -3153,18 +3154,17 @@ "item": { "$ref": "#/definitions/repo.ItemCreate" }, - "manufacturer": { + "notes": { + "description": "Extras", "type": "string" }, - "modelNumber": { - "description": "Identifications", + "search_engine_name": { "type": "string" }, - "notes": { - "description": "Extras", + "search_engine_product_url": { "type": "string" }, - "search_engine_name": { + "search_engine_url": { "type": "string" } } @@ -3292,6 +3292,9 @@ "name" ], "properties": { + "barcode": { + "type": "string" + }, "description": { "type": "string", "maxLength": 1000 @@ -3306,6 +3309,12 @@ "description": "Edges", "type": "string" }, + "manufacturer": { + "type": "string" + }, + "modelNumber": { + "type": "string" + }, "name": { "type": "string", "maxLength": 255, @@ -3359,6 +3368,9 @@ "$ref": "#/definitions/repo.ItemAttachment" } }, + "barcode": { + "type": "string" + }, "createdAt": { "type": "string" }, @@ -3588,6 +3600,9 @@ "assetId": { "type": "string" }, + "barcode": { + "type": "string" + }, "description": { "type": "string", "maxLength": 1000 diff --git a/docs/en/api/openapi-2.0.yaml b/docs/en/api/openapi-2.0.yaml index 8289cc185..c503effc6 100644 --- a/docs/en/api/openapi-2.0.yaml +++ b/docs/en/api/openapi-2.0.yaml @@ -247,6 +247,9 @@ definitions: asset_id: description: AssetID holds the value of the "asset_id" field. type: integer + barcode: + description: Barcode holds the value of the "barcode" field. + type: string created_at: description: CreatedAt holds the value of the "created_at" field. type: string @@ -648,24 +651,21 @@ definitions: - TypeTime repo.BarcodeProduct: properties: - barcode: - type: string imageBase64: type: string imageURL: type: string item: $ref: '#/definitions/repo.ItemCreate' - manufacturer: - type: string - modelNumber: - description: Identifications - type: string notes: description: Extras type: string search_engine_name: type: string + search_engine_product_url: + type: string + search_engine_url: + type: string type: object repo.DuplicateOptions: properties: @@ -745,6 +745,8 @@ definitions: type: object repo.ItemCreate: properties: + barcode: + type: string description: maxLength: 1000 type: string @@ -755,6 +757,10 @@ definitions: locationId: description: Edges type: string + manufacturer: + type: string + modelNumber: + type: string name: maxLength: 255 minLength: 1 @@ -793,6 +799,8 @@ definitions: items: $ref: '#/definitions/repo.ItemAttachment' type: array + barcode: + type: string createdAt: type: string description: @@ -946,6 +954,8 @@ definitions: type: boolean assetId: type: string + barcode: + type: string description: maxLength: 1000 type: string diff --git a/docs/en/configure/index.md b/docs/en/configure/index.md index b3147d6bb..037e40410 100644 --- a/docs/en/configure/index.md +++ b/docs/en/configure/index.md @@ -53,6 +53,7 @@ aside: false | HBOX_THUMBNAIL_ENABLED | true | enable thumbnail generation for images, supports PNG, JPEG, AVIF, WEBP, GIF file types | | HBOX_THUMBNAIL_WIDTH | 500 | width for generated thumbnails in pixels | | HBOX_THUMBNAIL_HEIGHT | 500 | height for generated thumbnails in pixels | +| HBOX_BARCODE_TOKEN_BARCODESPIDER | | API key for fetching item's information from *barcodespider.com* database (see [Autofill item information](/en/user-guide/tips-tricks.md#autofill-item-information)) | ### HBOX_WEB_HOST examples diff --git a/docs/en/user-guide/tips-tricks.md b/docs/en/user-guide/tips-tricks.md index 25265dbf0..fb0bc0f36 100644 --- a/docs/en/user-guide/tips-tricks.md +++ b/docs/en/user-guide/tips-tricks.md @@ -15,6 +15,20 @@ Custom fields are appended to the main details section of your item. Homebox Custom Fields also have special support for URLs. Provide a URL (`https://google.com`) and it will be automatically converted to a clickable link in the UI. Optionally, you can also use Markdown syntax to add a custom text to the button. `[Google](https://google.com)` ::: +## Autofill item information + +:label: 0.21.0 + +Homebox allows to scan barcodes that might be seen on some items. Adding new items to Homebox become faster as Homebox will fetch information linked to a scanned barcode from popular online database ([barcodespider.com](https://barcodespider.com) and [UPCItemDB](https://www.upcitemdb.com/) are currently supported). + +**Environment Variable:** `HBOX_BARCODE_TOKEN_BARCODESPIDER` if you want to use *barcodespider.com* database. + +There is two ways to use this feature: + +* From the *Add item* dialog: click on one of the barcode icons on the top right. You can either manually enter a barcode, or scan it using the QRCode scanner. +* Directly from *QR code scanner* dialog: once a barcode is detected, Homebox will ask you if you want to fetch the product information. + + ## Managing Asset IDs Homebox provides the option to auto-set asset IDs, this is the default behavior. These can be used for tracking assets with printable tags or labels. You can disable this behavior via a command line flag or ENV variable. See [configuration](/en/quick-start.md#env-variables-configuration) for more details. diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 87b6ac6a5..44fd2acd2 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -966,6 +966,16 @@ text-transform: none !important; } +/* For selectable card for barcodes */ +.selectable-card:hover.selected { + background-color: hsl(var(--primary)); +} + +.selectable-card.selected { + background-color: hsl(var(--primary)); + color: hsl(var(--background)); +} + /* transparent subtle scrollbar */ ::-webkit-scrollbar { width: 0.2em; diff --git a/frontend/components/App/CreateModal.vue b/frontend/components/App/CreateModal.vue index a2efe6906..61140ce7c 100644 --- a/frontend/components/App/CreateModal.vue +++ b/frontend/components/App/CreateModal.vue @@ -10,7 +10,7 @@ - + (); + withDefaults( + defineProps<{ + dialogId: DialogID; + title: string; + displayShortcut?: boolean; + }>(), + { + displayShortcut: true, + } + ); diff --git a/frontend/components/App/ScannerModal.vue b/frontend/components/App/ScannerModal.vue index e13cb2663..6e5805cc8 100644 --- a/frontend/components/App/ScannerModal.vue +++ b/frontend/components/App/ScannerModal.vue @@ -78,7 +78,7 @@ const handleError = (error: unknown) => { console.error("Scanner error:", error); - errorMessage.value = t("scanner.error"); + errorMessage.value = t("scanner.error") + ": " + error; }; const checkPermissionsError = async () => { diff --git a/frontend/components/Form/TextField.vue b/frontend/components/Form/TextField.vue index 6e81289e3..d332d0af4 100644 --- a/frontend/components/Form/TextField.vue +++ b/frontend/components/Form/TextField.vue @@ -19,6 +19,8 @@ v-model="value" :placeholder="placeholder" :type="type" + :inputmode="inputmode" + :pattern="pattern" :required="required" class="w-full" /> @@ -43,14 +45,18 @@ :placeholder="placeholder" :type="type" :required="required" + :inputmode="inputmode" + :pattern="pattern" class="col-span-3 mt-2 w-full" /> diff --git a/frontend/components/Item/BarcodeModal.vue b/frontend/components/Item/BarcodeModal.vue index 454d2626c..902888ff3 100644 --- a/frontend/components/Item/BarcodeModal.vue +++ b/frontend/components/Item/BarcodeModal.vue @@ -1,156 +1,87 @@ diff --git a/frontend/components/global/Barcode.vue b/frontend/components/global/Barcode.vue new file mode 100644 index 000000000..51f19a192 --- /dev/null +++ b/frontend/components/global/Barcode.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/components/global/DetailsSection/DetailsSection.vue b/frontend/components/global/DetailsSection/DetailsSection.vue index 785cd48c5..b00eb8e0d 100644 --- a/frontend/components/global/DetailsSection/DetailsSection.vue +++ b/frontend/components/global/DetailsSection/DetailsSection.vue @@ -12,6 +12,17 @@ :date="detail.text" :datetime-type="detail.date ? 'date' : 'datetime'" /> + diff --git a/frontend/pages/item/[id]/index/edit.vue b/frontend/pages/item/[id]/index/edit.vue index 54114811e..00b495ea0 100644 --- a/frontend/pages/item/[id]/index/edit.vue +++ b/frontend/pages/item/[id]/index/edit.vue @@ -200,6 +200,12 @@ ref: "manufacturer", maxLength: 255, }, + { + type: "text", + label: "items.barcode", + ref: "barcode", + maxLength: 255, + }, { type: "textarea", label: "items.notes", diff --git a/frontend/pages/label/[id].vue b/frontend/pages/label/[id].vue index d9abeaf44..dd2182e2f 100644 --- a/frontend/pages/label/[id].vue +++ b/frontend/pages/label/[id].vue @@ -192,7 +192,7 @@
- +
diff --git a/frontend/pages/location/[id].vue b/frontend/pages/location/[id].vue index 94e16add1..55a87720d 100644 --- a/frontend/pages/location/[id].vue +++ b/frontend/pages/location/[id].vue @@ -217,7 +217,7 @@
- +
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 54b96ce30..c3491f00e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: http-proxy: specifier: ^1.18.1 version: 1.18.1 + jsbarcode: + specifier: ^3.12.1 + version: 3.12.1 lucide-vue-next: specifier: ^0.474.0 version: 0.474.0(vue@3.4.8(typescript@5.6.2)) @@ -4367,6 +4370,9 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsbarcode@3.12.1: + resolution: {integrity: sha512-QZQSqIknC2Rr/YOUyOkCBqsoiBAOTYK+7yNN3JsqfoUtJtkazxNw1dmPpxuv7VVvqW13kA3/mKiLq+s/e3o9hQ==} + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -8569,7 +8575,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.10.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(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.10.0)(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 @@ -8582,10 +8588,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.10.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(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.10.0)(eslint@8.57.1)': dependencies: 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.10.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-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-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.10.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) @@ -10673,7 +10679,7 @@ snapshots: dependencies: 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.10.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-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): 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.10.0)(eslint@8.57.1) @@ -10703,7 +10709,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.10.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(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.10.0)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -10737,7 +10743,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.10.0(eslint-plugin-import@2.31.0)(eslint@8.57.1))(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.10.0)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11681,6 +11687,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbarcode@3.12.1: {} + jsesc@3.0.2: {} jsesc@3.1.0: {} @@ -12311,7 +12319,7 @@ snapshots: unenv: 1.10.0 unimport: 3.14.6(rollup@4.40.0) unplugin: 1.16.1 - unplugin-vue-router: 0.10.9(rollup@4.40.0)(vue-router@4.5.0(vue@3.5.13(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2)) + unplugin-vue-router: 0.10.9(rollup@4.40.0)(vue-router@4.5.0(vue@3.4.8(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2)) unstorage: 1.15.0(@netlify/blobs@8.2.0)(db0@0.3.2)(ioredis@5.6.1) untyped: 1.5.2 vue: 3.5.13(typescript@5.6.2) @@ -13942,7 +13950,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.2 - unplugin-vue-router@0.10.9(rollup@4.40.0)(vue-router@4.5.0(vue@3.5.13(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2)): + unplugin-vue-router@0.10.9(rollup@4.40.0)(vue-router@4.5.0(vue@3.4.8(typescript@5.6.2)))(vue@3.5.13(typescript@5.6.2)): dependencies: '@babel/types': 7.27.0 '@rollup/pluginutils': 5.1.4(rollup@4.40.0) @@ -13959,7 +13967,7 @@ snapshots: unplugin: 2.0.0-beta.1 yaml: 2.7.1 optionalDependencies: - vue-router: 4.5.0(vue@3.5.13(typescript@5.6.2)) + vue-router: 4.5.0(vue@3.4.8(typescript@5.6.2)) transitivePeerDependencies: - rollup - vue