Add ISBN-10 to ISBN-13 validation and conversion

Enhance NormalizeISBN to validate and convert ISBNs:
- Validate length (10 or 13 digits), return error if invalid
- Convert ISBN-10 to ISBN-13 by prefixing '978' and recalculating checksum
- Add NormalizeISBNSafe for backward compatibility in scanners

This ensures all ISBNs stored in database are valid ISBN-13 format.
This commit is contained in:
2026-02-11 09:42:18 -05:00
parent 0eee34ad01
commit 0f8db2ab07
6 changed files with 226 additions and 20 deletions
+15 -3
View File
@@ -913,7 +913,13 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
_, err := mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil {
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
}
_, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
@@ -936,7 +942,7 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error {
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Isbn: pgtype.Text{String: normalizedISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
FilePath: req.FilePath,
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
@@ -998,11 +1004,17 @@ func (mh *MediaHandler) UpdateMediaItem(c echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil {
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
}
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Isbn: pgtype.Text{String: normalizedISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
+1 -1
View File
@@ -458,7 +458,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) error
LibraryID: libraryID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{String: utils.NormalizeISBN(metadata.ISBN), Valid: metadata.ISBN != ""},
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
FilePath: path,
+52 -1
View File
@@ -1,12 +1,42 @@
package utils
import (
"errors"
"regexp"
)
var (
ErrInvalidISBN = errors.New("ISBN must be 10 or 13 digits")
)
// NormalizeISBN removes hyphens and spaces from ISBN to standardize format
// Handles ISBN-10 and ISBN-13 formats
func NormalizeISBN(isbn string) string {
// Returns error if ISBN is not 10 or 13 digits after normalization
// Converts ISBN-10 to ISBN-13 by prefixing with "978" and recalculating checksum
func NormalizeISBN(isbn string) (string, error) {
if isbn == "" {
return "", nil
}
// Remove hyphens and spaces, return only digits and X (for ISBN-10)
normalized := regexp.MustCompile(`[-\s]`).ReplaceAllString(isbn, "")
// Check length: must be 10 or 13 digits
length := len(normalized)
if length == 10 {
// Convert ISBN-10 to ISBN-13
return convertISBN10To13(normalized)
}
if length == 13 {
return normalized, nil
}
return "", ErrInvalidISBN
}
// NormalizeISBNSafe removes hyphens and spaces from ISBN without validation
// Used by scanners where metadata may be incomplete or malformed
func NormalizeISBNSafe(isbn string) string {
if isbn == "" {
return ""
}
@@ -14,3 +44,24 @@ func NormalizeISBN(isbn string) string {
// Remove hyphens and spaces, return only digits and X (for ISBN-10)
return regexp.MustCompile(`[-\s]`).ReplaceAllString(isbn, "")
}
// convertISBN10To13 converts ISBN-10 to ISBN-13 by prefixing "978" and recalculating checksum
func convertISBN10To13(isbn10 string) (string, error) {
// ISBN-10 to ISBN-13: prefix "978" and recalculate checksum
// Replace last digit (X becomes 0 for calculation purposes)
isbn12 := "978" + isbn10[:9]
// Calculate ISBN-13 checksum
sum := 0
for i := 0; i < 12; i++ {
digit := int(isbn12[i] - '0')
if i%2 == 0 {
sum += digit * 1
} else {
sum += digit * 3
}
}
checksum := (10 - (sum % 10)) % 10
return isbn12 + string(rune('0'+checksum)), nil
}
+6 -6
View File
@@ -66,7 +66,7 @@ func TestNormalizeISBN_ValidISBNs(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeISBN(tt.input)
result := NormalizeISBNSafe(tt.input)
assert.Equal(t, tt.expected, result)
})
}
@@ -117,7 +117,7 @@ func TestNormalizeISBN_EdgeCases(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeISBN(tt.input)
result := NormalizeISBNSafe(tt.input)
assert.Equal(t, tt.expected, result)
})
}
@@ -148,7 +148,7 @@ func TestNormalizeISBN_SpecialCharacters(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeISBN(tt.input)
result := NormalizeISBNSafe(tt.input)
assert.Equal(t, tt.expected, result)
})
}
@@ -189,7 +189,7 @@ func TestNormalizeISBN_RealWorldExamples(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeISBN(tt.input)
result := NormalizeISBNSafe(tt.input)
assert.Equal(t, tt.expected, result)
})
}
@@ -205,7 +205,7 @@ func TestNormalizeISBN_DoesNotModifyValidISBNs(t *testing.T) {
for _, isbn := range validISBNs {
t.Run(isbn, func(t *testing.T) {
result := NormalizeISBN(isbn)
result := NormalizeISBNSafe(isbn)
assert.Equal(t, isbn, result, "Valid ISBN should not be modified")
})
}
@@ -236,7 +236,7 @@ func TestNormalizeISBN_PreservesX(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeISBN(tt.input)
result := NormalizeISBNSafe(tt.input)
assert.Equal(t, tt.expected, result)
})
}
+118
View File
@@ -0,0 +1,118 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeISBN_Validation(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectError bool
}{
{
name: "valid ISBN-13",
input: "978-0-306-40615-7",
expected: "9780306406157",
expectError: false,
},
{
name: "valid ISBN-10 converts to ISBN-13",
input: "0-306-40615-2",
expected: "9780306406157",
expectError: false,
},
{
name: "ISBN-10 with X converts to ISBN-13",
input: "0-596-00965-X",
expected: "9780596009656",
expectError: false,
},
{
name: "empty string",
input: "",
expected: "",
expectError: false,
},
{
name: "11 digits - invalid",
input: "97801234567",
expected: "",
expectError: true,
},
{
name: "12 digits - invalid",
input: "978012345678",
expected: "",
expectError: true,
},
{
name: "14 digits - invalid",
input: "97801234567890",
expected: "",
expectError: true,
},
{
name: "only hyphens",
input: "---",
expected: "",
expectError: true,
},
{
name: "only text",
input: "not-an-isbn",
expected: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := NormalizeISBN(tt.input)
if tt.expectError {
require.Error(t, err)
assert.Equal(t, "", result)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestConvertISBN10To13(t *testing.T) {
tests := []struct {
name string
isbn10 string
expected string
}{
{
name: "ISBN-10 0-306-40615-2",
isbn10: "0306406152",
expected: "9780306406157",
},
{
name: "ISBN-10 0-596-00965-X (X checksum)",
isbn10: "059600965X",
expected: "9780596009656",
},
{
name: "ISBN-10 0-8044-2957-X",
isbn10: "080442957X",
expected: "9780804429573",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := convertISBN10To13(tt.isbn10)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}