Rename ean -> gtin. Consolidate the migrations
This commit is contained in:
@@ -69,7 +69,7 @@ class Components::Libraries::ShowView < Components::Base
|
||||
end
|
||||
div(class: "flex items-center mt-2 text-sm text-gray-500") do
|
||||
span(class: "bg-gray-100 px-2 py-1 rounded") { product.product_type.humanize }
|
||||
span(class: "ml-2") { "EAN: #{product.ean}" }
|
||||
span(class: "ml-2") { "GTIN: #{product.gtin}" }
|
||||
if library_item.condition.present?
|
||||
span(class: "ml-2") { "Condition: #{library_item.condition}" }
|
||||
end
|
||||
@@ -83,7 +83,7 @@ class Components::Libraries::ShowView < Components::Base
|
||||
end
|
||||
|
||||
div(class: "flex items-center space-x-2") do
|
||||
a(href: "/#{product.ean}", class: "text-blue-600 hover:text-blue-800") { "View" }
|
||||
a(href: "/#{product.gtin}", class: "text-blue-600 hover:text-blue-800") { "View" }
|
||||
form(method: "post", action: "/library_items/#{library_item.id}", class: "inline") do
|
||||
input(type: "hidden", name: "_method", value: "delete")
|
||||
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
|
||||
|
||||
@@ -48,10 +48,10 @@ class Components::Products::DisplayView < Components::Base
|
||||
p(class: "text-xs text-gray-500") { @product.publisher }
|
||||
end
|
||||
|
||||
# EAN badge
|
||||
# GTIN badge
|
||||
div(class: "mt-2") do
|
||||
span(class: "inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded font-mono") do
|
||||
@product.ean
|
||||
@product.gtin
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ class Components::Products::IndexView < Components::Base
|
||||
h2(class: "text-lg font-semibold text-gray-800 mb-4") { "Recent Additions" }
|
||||
div(class: "space-y-3") do
|
||||
@recent_products.each do |product|
|
||||
a(href: "/#{product.ean}", class: "block") do
|
||||
a(href: "/#{product.gtin}", class: "block") do
|
||||
div(class: "flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors") do
|
||||
div(class: "flex-shrink-0") do
|
||||
if product.cover_image.attached?
|
||||
|
||||
@@ -299,12 +299,12 @@ class Components::Scanners::AdaptiveView < Components::Base
|
||||
div(class: "flex items-center gap-2") do
|
||||
div(class: "text-xs text-gray-500") { scan.created_at.strftime("%H:%M") }
|
||||
div(class: "flex-1 text-sm font-medium text-gray-800 truncate") do
|
||||
scan.product.title || scan.product.ean
|
||||
scan.product.title || scan.product.gtin
|
||||
end
|
||||
end
|
||||
else
|
||||
div(class: "text-sm text-gray-500") do
|
||||
"EAN: #{scan.ean}"
|
||||
"GTIN: #{scan.gtin}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -211,12 +211,12 @@ class Components::Scanners::HorizontalView < Components::Base
|
||||
div(class: "flex items-center gap-2") do
|
||||
div(class: "text-xs text-gray-500") { scan.created_at.strftime("%H:%M") }
|
||||
div(class: "flex-1 text-sm font-medium text-gray-800 truncate") do
|
||||
scan.product.title || scan.product.ean
|
||||
scan.product.title || scan.product.gtin
|
||||
end
|
||||
end
|
||||
else
|
||||
div(class: "text-sm text-gray-500") do
|
||||
"EAN: #{scan.ean}"
|
||||
"GTIN: #{scan.gtin}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -337,12 +337,12 @@ class Components::Scanners::IndexView < Components::Base
|
||||
div(class: "flex items-center gap-2") do
|
||||
div(class: "text-xs text-gray-500") { scan.created_at.strftime("%H:%M") }
|
||||
div(class: "flex-1 text-sm font-medium text-gray-800 truncate") do
|
||||
scan.product.title || scan.product.ean
|
||||
scan.product.title || scan.product.gtin
|
||||
end
|
||||
end
|
||||
else
|
||||
div(class: "text-sm text-gray-500") do
|
||||
"EAN: #{scan.ean}"
|
||||
"GTIN: #{scan.gtin}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ module Components
|
||||
class ScanItemView < Phlex::HTML
|
||||
include ActionView::Helpers::DateHelper
|
||||
include ActionView::RecordIdentifier
|
||||
include Phlex::Rails::Helpers::URLFor
|
||||
|
||||
def initialize(scan:)
|
||||
@scan = scan
|
||||
@@ -35,7 +36,7 @@ module Components
|
||||
# Product details
|
||||
div(class: "flex-grow") do
|
||||
h3(class: "font-semibold text-gray-900") do
|
||||
a(href: "/#{@scan.product.ean}", class: "hover:text-blue-600") do
|
||||
a(href: "/#{@scan.product.gtin}", class: "hover:text-blue-600") do
|
||||
@scan.product.safe_title
|
||||
end
|
||||
end
|
||||
@@ -45,7 +46,7 @@ module Components
|
||||
end
|
||||
|
||||
p(class: "text-xs text-gray-500 mt-1") do
|
||||
"EAN: #{@scan.product.ean}"
|
||||
"GTIN: #{@scan.product.gtin}"
|
||||
end
|
||||
|
||||
if @scan.user.present?
|
||||
|
||||
@@ -34,13 +34,7 @@ class Components::Shared::NavigationView < Components::Base
|
||||
"Libraries"
|
||||
end
|
||||
a(href: scans_path, class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors") do
|
||||
"My Scans"
|
||||
end
|
||||
a(href: users_path, class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors") do
|
||||
"Users"
|
||||
end
|
||||
a(href: "/api/v1", class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors") do
|
||||
"API"
|
||||
"Scans"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -55,7 +49,7 @@ class Components::Shared::NavigationView < Components::Base
|
||||
end
|
||||
a(
|
||||
href: signout_path,
|
||||
method: :delete,
|
||||
data: { turbo_method: :delete },
|
||||
class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
) do
|
||||
"Sign out"
|
||||
|
||||
@@ -54,6 +54,56 @@ class Components::User::ProfileEditView < Components::Base
|
||||
p(class: "mt-2 text-sm text-red-600") { @user.errors[:email_address].first }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# API Configuration Section
|
||||
div(id: "api-settings", class: "px-6 py-6 border-t border-gray-200") do
|
||||
h3(class: "text-lg font-medium text-gray-900 mb-6") { "API Configuration" }
|
||||
|
||||
form_with(url: "/profile/api_token", method: :patch, local: true, class: "space-y-6") do |api_form|
|
||||
div do
|
||||
api_form.label :thebookdb_api_token, "Personal TheBookDB API Token", class: "block text-sm font-medium text-gray-700"
|
||||
p(class: "text-xs text-gray-500 mt-1 mb-2") do
|
||||
plain "Enter your personal API token for TheBookDB.info service. "
|
||||
if ENV["TBDB_API_TOKEN"].present?
|
||||
plain "If left blank, the application will use the system default token."
|
||||
else
|
||||
plain "This is required as no system default is configured."
|
||||
end
|
||||
end
|
||||
api_form.password_field :thebookdb_api_token,
|
||||
value: @user.thebookdb_api_token,
|
||||
placeholder: "Enter your personal API token...",
|
||||
class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
|
||||
if @user.has_thebookdb_api_token?
|
||||
p(class: "text-xs text-gray-400 mt-1") { "Leave blank to remove your personal token and use application default" }
|
||||
end
|
||||
end
|
||||
|
||||
div(class: "flex justify-end space-x-3") do
|
||||
if @user.has_thebookdb_api_token?
|
||||
a(
|
||||
href: "/profile/api_token",
|
||||
data: { method: :delete, confirm: "Are you sure you want to remove your personal API token?" },
|
||||
class: "px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-300 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
) do
|
||||
"Remove Token"
|
||||
end
|
||||
end
|
||||
api_form.submit(@user.has_thebookdb_api_token? ? "Update Token" : "Set Token",
|
||||
class: "px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
div(class: "px-6 py-6") do
|
||||
form_with(model: @user, url: profile_path, method: :patch, local: true, class: "space-y-6") do |f|
|
||||
div(class: "hidden") do
|
||||
# Hidden fields to avoid affecting profile form
|
||||
end
|
||||
|
||||
# Actions
|
||||
div(class: "mt-8 flex justify-end space-x-4") do
|
||||
|
||||
@@ -70,8 +70,8 @@ class Components::User::ProfileView < Components::Base
|
||||
# Hide invalid barcodes setting
|
||||
div(class: "flex items-center justify-between") do
|
||||
div do
|
||||
dt(class: "text-sm font-medium text-gray-700") { "Hide Invalid ISBNs/EAN13 barcodes" }
|
||||
dd(class: "text-xs text-gray-500 mt-1") { "Hide products with invalid EAN-13 check digits or non-ISBN barcodes from listings" }
|
||||
dt(class: "text-sm font-medium text-gray-700") { "Hide Invalid ISBNs/GTIN barcodes" }
|
||||
dd(class: "text-xs text-gray-500 mt-1") { "Hide products with invalid GTIN check digits or non-ISBN barcodes from listings" }
|
||||
end
|
||||
div(class: "ml-4") do
|
||||
label(class: "relative inline-flex items-center cursor-pointer") do
|
||||
@@ -99,6 +99,37 @@ class Components::User::ProfileView < Components::Base
|
||||
end
|
||||
end
|
||||
|
||||
# API Configuration Section
|
||||
div(class: "px-6 py-6 border-t border-gray-200") do
|
||||
h3(class: "text-lg font-medium text-gray-900 mb-6") { "API Configuration" }
|
||||
|
||||
div(class: "space-y-4") do
|
||||
div do
|
||||
dt(class: "text-sm font-medium text-gray-700 mb-2") { "TheBookDB API Token" }
|
||||
dd(class: "text-xs text-gray-500 mb-3") { "Personal API token for accessing TheBookDB.info service. Falls back to application default if not set." }
|
||||
|
||||
if @user.has_thebookdb_api_token?
|
||||
div(class: "text-sm text-gray-900 font-mono bg-gray-50 px-3 py-2 rounded border") do
|
||||
token = @user.thebookdb_api_token
|
||||
masked_token = token[0..7] + "..." + token[-4..-1]
|
||||
masked_token
|
||||
end
|
||||
div(class: "text-xs text-green-600 mt-1") { "Using personal token" }
|
||||
else
|
||||
if ENV["TBDB_API_TOKEN"].present?
|
||||
div(class: "text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded border") do
|
||||
"Using application default"
|
||||
end
|
||||
else
|
||||
div(class: "text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-200") do
|
||||
"No token configured"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Actions
|
||||
div(class: "px-6 py-4 bg-gray-50 border-t border-gray-200") do
|
||||
div(class: "flex justify-end space-x-4") do
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::ProductsController < Api::V1::BaseController
|
||||
def product_json(product)
|
||||
{
|
||||
id: product.id,
|
||||
ean: product.ean,
|
||||
gtin: product.gtin,
|
||||
title: product.title,
|
||||
author: product.author,
|
||||
publisher: product.publisher,
|
||||
|
||||
@@ -17,7 +17,7 @@ class ProductsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
# For EAN-13 URLs, render as Turbo Stream for scanner integration
|
||||
# For GTIN URLs, render as Turbo Stream for scanner integration
|
||||
if request.headers["Accept"]&.include?("text/vnd.turbo-stream.html")
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"product-display",
|
||||
@@ -87,10 +87,10 @@ class ProductsController < ApplicationController
|
||||
private
|
||||
|
||||
def find_or_create_product
|
||||
ean = params[:ean13] || params[:id]
|
||||
gtin = params[:gtin13] || params[:id]
|
||||
|
||||
begin
|
||||
@product = Product.find_or_create_by_ean(ean, {
|
||||
@product = Product.find_or_create_by_gtin(gtin, {
|
||||
product_type: "book" # Default assumption
|
||||
})
|
||||
rescue ArgumentError => e
|
||||
|
||||
@@ -11,11 +11,11 @@ class ScansController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
# Accept either product_id or ean parameter
|
||||
# Accept either product_id or gtin parameter
|
||||
if params[:product_id]
|
||||
product = Product.find(params[:product_id])
|
||||
elsif params[:ean]
|
||||
product = Product.find_or_create_by_ean(params[:ean])
|
||||
elsif params[:gtin]
|
||||
product = Product.find_or_create_by_gtin(params[:gtin])
|
||||
else
|
||||
head :bad_request
|
||||
return
|
||||
@@ -41,7 +41,7 @@ class ScansController < ApplicationController
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
head :not_found
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Invalid EAN: #{e.message}"
|
||||
Rails.logger.error "Invalid GTIN: #{e.message}"
|
||||
head :bad_request
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to track scan: #{e.message}"
|
||||
|
||||
@@ -60,6 +60,31 @@ class UserController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def update_api_token
|
||||
@user = Current.user
|
||||
token = params[:thebookdb_api_token].present? ? params[:thebookdb_api_token].strip : nil
|
||||
|
||||
if token.present?
|
||||
# Basic validation
|
||||
if token.length < 10
|
||||
redirect_to edit_profile_path, alert: "API token appears to be too short. Please check your token."
|
||||
return
|
||||
end
|
||||
|
||||
@user.thebookdb_api_token = token
|
||||
redirect_to profile_path, notice: "Personal API token updated successfully."
|
||||
else
|
||||
@user.thebookdb_api_token = nil
|
||||
redirect_to profile_path, notice: "Personal API token removed. Using application default."
|
||||
end
|
||||
end
|
||||
|
||||
def delete_api_token
|
||||
@user = Current.user
|
||||
@user.thebookdb_api_token = nil
|
||||
redirect_to profile_path, notice: "Personal API token removed. Using application default."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
|
||||
@@ -254,7 +254,7 @@ export default class extends Controller {
|
||||
onScanSuccess(decodedText, decodedResult) {
|
||||
console.log(`Barcode detected (${this.currentOrientationValue} mode):`, decodedText)
|
||||
|
||||
// Validate EAN-13 format (13 digits)
|
||||
// Validate GTIN-13 format (13 digits)
|
||||
if (decodedText && /^\d{13}$/.test(decodedText)) {
|
||||
// Update result display
|
||||
const resultText = `✅ Scanned: ${decodedText}`
|
||||
@@ -277,7 +277,7 @@ export default class extends Controller {
|
||||
this.fetchProductData(decodedText)
|
||||
}
|
||||
} else {
|
||||
console.log("Invalid EAN-13 format:", decodedText)
|
||||
console.log("Invalid GTIN-13 format:", decodedText)
|
||||
this.showError(`Invalid barcode format: ${decodedText}`)
|
||||
}
|
||||
}
|
||||
@@ -287,10 +287,10 @@ export default class extends Controller {
|
||||
// We don't need to log every failure as it's normal during scanning
|
||||
}
|
||||
|
||||
async fetchProductData(ean) {
|
||||
async fetchProductData(gtin) {
|
||||
try {
|
||||
// First, get the product data
|
||||
const response = await fetch(`/${ean}`, {
|
||||
const response = await fetch(`/${gtin}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/vnd.turbo-stream.html',
|
||||
@@ -304,7 +304,7 @@ export default class extends Controller {
|
||||
Turbo.renderStreamMessage(turboStreamData)
|
||||
|
||||
// Track the scan after successful product fetch
|
||||
await this.trackScan(ean)
|
||||
await this.trackScan(gtin)
|
||||
} else {
|
||||
this.showError('Failed to load product data')
|
||||
}
|
||||
@@ -314,7 +314,7 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async trackScan(ean) {
|
||||
async trackScan(gtin) {
|
||||
try {
|
||||
await fetch('/scans', {
|
||||
method: 'POST',
|
||||
@@ -322,7 +322,7 @@ export default class extends Controller {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({ ean: ean })
|
||||
body: JSON.stringify({ gtin: gtin })
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error tracking scan:', error)
|
||||
@@ -344,9 +344,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
// Scanner page specific methods
|
||||
handleScannerPageScan(ean) {
|
||||
handleScannerPageScan(gtin) {
|
||||
// Just track the scan - same as existing scanner
|
||||
this.trackScan(ean)
|
||||
this.trackScan(gtin)
|
||||
|
||||
// Continue scanning after a brief pause
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -312,7 +312,7 @@ export default class extends Controller {
|
||||
onScanSuccess(decodedText, decodedResult) {
|
||||
console.log(`Barcode detected (${this.currentOrientationValue} mode):`, decodedText)
|
||||
|
||||
// Validate EAN-13 format (13 digits)
|
||||
// Validate GTIN-13 format (13 digits)
|
||||
if (decodedText && /^\d{13}$/.test(decodedText)) {
|
||||
// Update result display
|
||||
const resultText = `✅ Scanned: ${decodedText}`
|
||||
@@ -335,7 +335,7 @@ export default class extends Controller {
|
||||
this.fetchProductData(decodedText)
|
||||
}
|
||||
} else {
|
||||
console.log("Invalid EAN-13 format:", decodedText)
|
||||
console.log("Invalid GTIN-13 format:", decodedText)
|
||||
this.showError(`Invalid barcode format: ${decodedText}`)
|
||||
}
|
||||
}
|
||||
@@ -345,10 +345,10 @@ export default class extends Controller {
|
||||
// We don't need to log every failure as it's normal during scanning
|
||||
}
|
||||
|
||||
async fetchProductData(ean) {
|
||||
async fetchProductData(gtin) {
|
||||
try {
|
||||
// First, get the product data
|
||||
const response = await fetch(`/${ean}`, {
|
||||
const response = await fetch(`/${gtin}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/vnd.turbo-stream.html',
|
||||
@@ -362,7 +362,7 @@ export default class extends Controller {
|
||||
Turbo.renderStreamMessage(turboStreamData)
|
||||
|
||||
// Track the scan after successful product fetch
|
||||
await this.trackScan(ean)
|
||||
await this.trackScan(gtin)
|
||||
} else {
|
||||
this.showError('Failed to load product data')
|
||||
}
|
||||
@@ -372,7 +372,7 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async trackScan(ean) {
|
||||
async trackScan(gtin) {
|
||||
try {
|
||||
await fetch('/scans', {
|
||||
method: 'POST',
|
||||
@@ -380,7 +380,7 @@ export default class extends Controller {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({ ean: ean })
|
||||
body: JSON.stringify({ gtin: gtin })
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error tracking scan:', error)
|
||||
@@ -483,9 +483,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
// Scanner page specific methods
|
||||
handleScannerPageScan(ean) {
|
||||
handleScannerPageScan(gtin) {
|
||||
// Just track the scan - same as existing scanner
|
||||
this.trackScan(ean)
|
||||
this.trackScan(gtin)
|
||||
|
||||
// Continue scanning after a brief pause
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -149,7 +149,7 @@ export default class extends Controller {
|
||||
onScanSuccess(decodedText, decodedResult) {
|
||||
console.log("Barcode detected (horizontal):", decodedText)
|
||||
|
||||
// Validate EAN-13 format (13 digits)
|
||||
// Validate GTIN-13 format (13 digits)
|
||||
if (decodedText && /^\d{13}$/.test(decodedText)) {
|
||||
// Update result display
|
||||
const resultText = `✅ Scanned: ${decodedText}`
|
||||
@@ -172,7 +172,7 @@ export default class extends Controller {
|
||||
this.fetchProductData(decodedText)
|
||||
}
|
||||
} else {
|
||||
console.log("Invalid EAN-13 format:", decodedText)
|
||||
console.log("Invalid GTIN-13 format:", decodedText)
|
||||
this.showError(`Invalid barcode format: ${decodedText}`)
|
||||
}
|
||||
}
|
||||
@@ -182,10 +182,10 @@ export default class extends Controller {
|
||||
// We don't need to log every failure as it's normal during scanning
|
||||
}
|
||||
|
||||
async fetchProductData(ean) {
|
||||
async fetchProductData(gtin) {
|
||||
try {
|
||||
// First, get the product data
|
||||
const response = await fetch(`/${ean}`, {
|
||||
const response = await fetch(`/${gtin}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/vnd.turbo-stream.html',
|
||||
@@ -199,7 +199,7 @@ export default class extends Controller {
|
||||
Turbo.renderStreamMessage(turboStreamData)
|
||||
|
||||
// Track the scan after successful product fetch
|
||||
await this.trackScan(ean)
|
||||
await this.trackScan(gtin)
|
||||
} else {
|
||||
this.showError('Failed to load product data')
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async trackScan(ean) {
|
||||
async trackScan(gtin) {
|
||||
try {
|
||||
await fetch('/scans', {
|
||||
method: 'POST',
|
||||
@@ -217,7 +217,7 @@ export default class extends Controller {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({ ean: ean })
|
||||
body: JSON.stringify({ gtin: gtin })
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error tracking scan:', error)
|
||||
@@ -239,9 +239,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
// Scanner page specific methods
|
||||
handleScannerPageScan(ean) {
|
||||
handleScannerPageScan(gtin) {
|
||||
// Just track the scan - same as existing scanner
|
||||
this.trackScan(ean)
|
||||
this.trackScan(gtin)
|
||||
|
||||
// Continue scanning after a brief pause
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -5,6 +5,6 @@ class ProductDataFetchJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(product)
|
||||
ProductEnrichmentService.new(tbdb_client: TBDB.client).call(product)
|
||||
ProductEnrichmentService.new.call(product)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ class Product < ApplicationRecord
|
||||
|
||||
has_one_attached :cover_image
|
||||
|
||||
validates :ean, presence: true, uniqueness: true, format: { with: /\A\d{13}\z/, message: "must be 13 digits" }
|
||||
validates :gtin, presence: true, uniqueness: true, format: { with: /\A\d{13}\z/, message: "must be 13 digits" }
|
||||
# validates :title, presence: true, on: :update
|
||||
validates :product_type, presence: true
|
||||
|
||||
@@ -23,14 +23,14 @@ class Product < ApplicationRecord
|
||||
scope :valid_barcodes, -> { where(valid_barcode: true) }
|
||||
scope :invalid_barcodes, -> { where(valid_barcode: false) }
|
||||
|
||||
# Find or create by EAN with basic product info
|
||||
def self.find_or_create_by_ean(ean, basic_info = {})
|
||||
# Validate EAN format
|
||||
unless ean&.match?(/\A\d{13}\z/)
|
||||
raise ArgumentError, "Invalid EAN-13 format: #{ean}"
|
||||
# Find or create by GTIN with basic product info
|
||||
def self.find_or_create_by_gtin(gtin, basic_info = {})
|
||||
# Validate GTIN format
|
||||
unless gtin&.match?(/\A\d{13}\z/)
|
||||
raise ArgumentError, "Invalid GTIN format: #{gtin}"
|
||||
end
|
||||
|
||||
find_or_create_by(ean: ean) do |product|
|
||||
find_or_create_by(gtin: gtin) do |product|
|
||||
product.title = basic_info[:title]
|
||||
product.author = basic_info[:author]
|
||||
product.publisher = basic_info[:publisher]
|
||||
@@ -38,7 +38,7 @@ class Product < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def self.findd(...) = find_or_create_by_ean(...)
|
||||
def self.findd(...) = find_or_create_by_gtin(...)
|
||||
|
||||
# Check if product data has been successfully enriched from TBDB
|
||||
def enriched?
|
||||
@@ -65,7 +65,7 @@ class Product < ApplicationRecord
|
||||
|
||||
def enrich!
|
||||
update(tbdb_data: {})
|
||||
ProductEnrichmentService.new(tbdb_client: TBDB.client).call(self)
|
||||
ProductEnrichmentService.new.call(self)
|
||||
end
|
||||
|
||||
# Library helpers
|
||||
@@ -77,9 +77,9 @@ class Product < ApplicationRecord
|
||||
library_items.where(library: library).count
|
||||
end
|
||||
|
||||
# Safe title that falls back to EAN if title is not set
|
||||
# Safe title that falls back to GTIN if title is not set
|
||||
def safe_title
|
||||
title.presence || "Product #{ean}"
|
||||
title.presence || "Product #{gtin}"
|
||||
end
|
||||
|
||||
private
|
||||
@@ -97,6 +97,6 @@ class Product < ApplicationRecord
|
||||
|
||||
# Validate and set the valid_barcode flag
|
||||
def validate_barcode
|
||||
self.valid_barcode = BarcodeValidationService.valid_barcode?(ean) if ean.present?
|
||||
self.valid_barcode = BarcodeValidationService.valid_barcode?(gtin) if gtin.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,4 +18,21 @@ class User < ApplicationRecord
|
||||
def get_setting(key, default = nil)
|
||||
user_settings.fetch(key.to_s, default)
|
||||
end
|
||||
|
||||
# API token management
|
||||
def thebookdb_api_token
|
||||
get_setting("thebookdb_api_token")
|
||||
end
|
||||
|
||||
def thebookdb_api_token=(token)
|
||||
update_setting("thebookdb_api_token", token.present? ? token.strip : nil)
|
||||
end
|
||||
|
||||
def has_thebookdb_api_token?
|
||||
thebookdb_api_token.present?
|
||||
end
|
||||
|
||||
def effective_thebookdb_api_token
|
||||
has_thebookdb_api_token? ? thebookdb_api_token : ENV["TBDB_API_TOKEN"]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
class BarcodeValidationService
|
||||
# Validates EAN-13 barcodes and determines if they represent valid ISBNs
|
||||
|
||||
def self.valid_ean13?(ean)
|
||||
new(ean).valid_ean13?
|
||||
def self.valid_ean13?(gtin)
|
||||
new(gtin).valid_ean13?
|
||||
end
|
||||
|
||||
def self.valid_isbn?(ean)
|
||||
new(ean).valid_isbn?
|
||||
def self.valid_isbn?(gtin)
|
||||
new(gtin).valid_isbn?
|
||||
end
|
||||
|
||||
def self.valid_barcode?(ean)
|
||||
new(ean).valid_barcode?
|
||||
def self.valid_barcode?(gtin)
|
||||
new(gtin).valid_barcode?
|
||||
end
|
||||
|
||||
def initialize(ean)
|
||||
@ean = ean.to_s.strip
|
||||
def initialize(gtin)
|
||||
@gtin = gtin.to_s.strip
|
||||
end
|
||||
|
||||
# Check if the EAN-13 has a valid format and check digit
|
||||
@@ -37,18 +37,18 @@ class BarcodeValidationService
|
||||
private
|
||||
|
||||
def basic_format_valid?
|
||||
@ean.match?(/\A\d{13}\z/)
|
||||
@gtin.match?(/\A\d{13}\z/)
|
||||
end
|
||||
|
||||
def isbn_prefix?
|
||||
@ean.start_with?("978", "979")
|
||||
@gtin.start_with?("978", "979")
|
||||
end
|
||||
|
||||
# EAN-13 check digit validation using modulo 10 weighted sum
|
||||
def valid_check_digit?
|
||||
return false unless basic_format_valid?
|
||||
|
||||
digits = @ean.chars.map(&:to_i)
|
||||
digits = @gtin.chars.map(&:to_i)
|
||||
check_digit = digits.pop
|
||||
|
||||
# Calculate weighted sum (multiply odd positions by 1, even positions by 3)
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
class ProductEnrichmentService
|
||||
def initialize(tbdb_client: TBDB.client)
|
||||
@tbdb_client = tbdb_client
|
||||
def initialize(tbdb_service: nil, user: nil)
|
||||
@tbdb_service = tbdb_service || ShelfLife::TbdbService.new(user: user)
|
||||
end
|
||||
|
||||
def call(product)
|
||||
Rails.logger.info "Enriching product #{product.ean} from TBDB"
|
||||
Rails.logger.info "Enriching product #{product.gtin} from TBDB"
|
||||
|
||||
# Skip if already enriched
|
||||
if product.enriched?
|
||||
Rails.logger.debug "Product #{product.ean} already has TBDB data, skipping"
|
||||
Rails.logger.debug "Product #{product.gtin} already has TBDB data, skipping"
|
||||
return product
|
||||
end
|
||||
|
||||
begin
|
||||
tbdb_data = fetch_tbdb_data(product.ean)
|
||||
tbdb_data = fetch_tbdb_data(product.gtin)
|
||||
|
||||
if tbdb_data.present?
|
||||
update_product_attributes(product, tbdb_data)
|
||||
attach_cover_image(product, tbdb_data["cover_url"]) if tbdb_data["cover_url"].present?
|
||||
mark_enrichment_status(product, "success", tbdb_data)
|
||||
Rails.logger.info "Successfully enriched product #{product.ean} with TBDB data"
|
||||
Rails.logger.info "Successfully enriched product #{product.gtin} with TBDB data"
|
||||
else
|
||||
mark_enrichment_status(product, "not_found", "Product not found in TBDB database")
|
||||
Rails.logger.info "Product #{product.ean} not found in TBDB database"
|
||||
Rails.logger.info "Product #{product.gtin} not found in TBDB database"
|
||||
end
|
||||
|
||||
product
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Error enriching product #{product.ean}: #{e.message}"
|
||||
Rails.logger.error "Error enriching product #{product.gtin}: #{e.message}"
|
||||
mark_enrichment_status(product, "error", e.message)
|
||||
raise e # Re-raise to trigger job retry mechanism
|
||||
end
|
||||
@@ -36,19 +36,19 @@ class ProductEnrichmentService
|
||||
|
||||
private
|
||||
|
||||
def fetch_tbdb_data(ean)
|
||||
tbdb_response = @tbdb_client.get_product(ean)
|
||||
def fetch_tbdb_data(gtin)
|
||||
tbdb_response = @tbdb_service.get_product(gtin)
|
||||
return nil unless tbdb_response.present?
|
||||
|
||||
# Extract data from response structure
|
||||
tbdb_data = tbdb_response["data"] if tbdb_response.key?("data")
|
||||
tbdb_data ||= tbdb_response
|
||||
|
||||
# Verify EAN matches (TBDB should return exact match)
|
||||
if tbdb_data["gtin"] == ean
|
||||
# Verify GTIN matches (TBDB should return exact match)
|
||||
if tbdb_data["gtin"] == gtin
|
||||
tbdb_data
|
||||
else
|
||||
Rails.logger.warn "TBDB returned product with different EAN: expected #{ean}, got #{tbdb_data['gtin']}"
|
||||
Rails.logger.warn "TBDB returned product with different GTIN: expected #{gtin}, got #{tbdb_data['gtin']}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -87,26 +87,32 @@ class ProductEnrichmentService
|
||||
return if product.cover_image.attached? # Don't overwrite existing image
|
||||
|
||||
begin
|
||||
Rails.logger.info "Downloading cover image for product #{product.ean} from #{cover_url}"
|
||||
Rails.logger.info "Downloading cover image for product #{product.gtin} from #{cover_url}"
|
||||
|
||||
# Download the image
|
||||
response = HTTP.follow.get(cover_url)
|
||||
return unless response.status.success?
|
||||
uri = URI.parse(cover_url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == 'https'
|
||||
|
||||
request = Net::HTTP::Get.new(uri)
|
||||
response = http.request(request)
|
||||
|
||||
return unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
# Extract filename from URL or generate one
|
||||
filename = extract_filename_from_url(cover_url) || "cover_#{product.ean}.jpg"
|
||||
filename = extract_filename_from_url(cover_url) || "cover_#{product.gtin}.jpg"
|
||||
|
||||
# Attach the image
|
||||
product.cover_image.attach(
|
||||
io: StringIO.new(response.body.to_s),
|
||||
io: StringIO.new(response.body),
|
||||
filename: filename,
|
||||
content_type: response.content_type.mime_type
|
||||
content_type: response['content-type'] || 'image/jpeg'
|
||||
)
|
||||
|
||||
Rails.logger.info "Successfully attached cover image for product #{product.ean}"
|
||||
Rails.logger.info "Successfully attached cover image for product #{product.gtin}"
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to download cover image for product #{product.ean}: #{e.message}"
|
||||
Rails.logger.error "Failed to download cover image for product #{product.gtin}: #{e.message}"
|
||||
# Don't re-raise - cover image failure shouldn't fail the whole enrichment
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
|
||||
<body>
|
||||
<%= render Components::Shared::NavigationView.new %>
|
||||
|
||||
<%= render Components::Shared::FlashMessagesView.new %>
|
||||
|
||||
<main class="container mx-auto mt-20 px-5 flex">
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t reg.getshelflife.app/shelflife:latest -t reg.getshelflife.app/shelflife:$(git rev-parse HEAD) --push .
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t shelflife/getshelflife:latest \
|
||||
-t shelflife/getshelflife:$(git rev-parse HEAD) \
|
||||
--push .
|
||||
|
||||
@@ -72,4 +72,5 @@ Rails.application.configure do
|
||||
|
||||
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
|
||||
# config.generators.apply_rubocop_autocorrect_after_generate!
|
||||
config.hosts << "7641b6bf7d4c.ngrok-free.app"
|
||||
end
|
||||
|
||||
@@ -50,6 +50,8 @@ Rails.application.routes.draw do
|
||||
get "/profile/edit", to: "user#edit", as: :edit_profile
|
||||
patch "/profile", to: "user#update"
|
||||
patch "/profile/settings", to: "user#update_setting"
|
||||
patch "/profile/api_token", to: "user#update_api_token"
|
||||
delete "/profile/api_token", to: "user#delete_api_token"
|
||||
get "/profile/change_password", to: "user#change_password", as: :change_password
|
||||
patch "/profile/update_password", to: "user#update_password"
|
||||
|
||||
@@ -62,6 +64,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
# EAN-13 barcode route - must be last to avoid conflicts
|
||||
get "/:ean13", to: "products#show", constraints: { ean13: /\d{13}/ }
|
||||
# GTIN route - must be last to avoid conflicts
|
||||
get "/:gtin", to: "products#show", constraints: { gtin: /\d{13}/ }
|
||||
end
|
||||
|
||||
@@ -2,7 +2,8 @@ class CreateProducts < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :products, id: false do |t|
|
||||
t.string :id, primary_key: true, default: -> { "ULID()" }
|
||||
t.string :ean, null: false
|
||||
t.string :gtin, null: false
|
||||
t.boolean :valid_barcode, default: true
|
||||
t.string :title
|
||||
t.string :subtitle
|
||||
t.string :author
|
||||
@@ -17,7 +18,7 @@ class CreateProducts < ActiveRecord::Migration[8.0]
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :products, :ean, unique: true
|
||||
add_index :products, :gtin, unique: true
|
||||
add_index :products, :product_type
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,9 @@ class CreateUsers < ActiveRecord::Migration[8.0]
|
||||
create_table :users, id: :string, default: -> { "ULID()" } do |t|
|
||||
t.string :email_address, null: false
|
||||
t.string :password_digest, null: false
|
||||
t.string :name
|
||||
t.json :user_settings, default: {}
|
||||
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddNameToUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :name, :string
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddUserSettingsToUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :user_settings, :json, default: {}
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddValidBarcodeToProducts < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :products, :valid_barcode, :boolean, default: true
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
class BackfillProductValidBarcodes < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
say "Backfilling valid_barcode for existing products..."
|
||||
|
||||
Product.find_each do |product|
|
||||
valid_barcode = BarcodeValidationService.valid_barcode?(product.ean)
|
||||
product.update_column(:valid_barcode, valid_barcode)
|
||||
print "."
|
||||
end
|
||||
|
||||
say "\nBackfill complete!"
|
||||
end
|
||||
|
||||
def down
|
||||
# Set all products back to valid (the original default)
|
||||
Product.update_all(valid_barcode: true)
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,7 @@ puts "Created #{Library.count} libraries: #{Library.pluck(:name).join(', ')}"
|
||||
# Create some sample products for testing
|
||||
sample_products = [
|
||||
{
|
||||
ean: '9780143058144',
|
||||
gtin: '9780143058144',
|
||||
title: 'The Hitchhiker\'s Guide to the Galaxy',
|
||||
author: 'Douglas Adams',
|
||||
publisher: 'Pan Books',
|
||||
@@ -22,7 +22,7 @@ sample_products = [
|
||||
genre: 'Science Fiction'
|
||||
},
|
||||
{
|
||||
ean: '9780747532743',
|
||||
gtin: '9780747532743',
|
||||
title: 'Harry Potter and the Philosopher\'s Stone',
|
||||
author: 'J.K. Rowling',
|
||||
publisher: 'Bloomsbury',
|
||||
@@ -32,7 +32,7 @@ sample_products = [
|
||||
]
|
||||
|
||||
sample_products.each do |product_attrs|
|
||||
Product.find_or_create_by(ean: product_attrs[:ean]) do |product|
|
||||
Product.find_or_create_by(gtin: product_attrs[:gtin]) do |product|
|
||||
product_attrs.each { |key, value| product.send("#{key}=", value) }
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user