mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): clean pasted identifiers instead of truncating them
ISBNs copied from Amazon carry hyphens, and copied ASINs often start with a zero-width space. The edit tab cut a hyphenated ISBN at its maxlength, Book Dock sent it on for the server to reject, and an invisible character left an ID that looked right but never matched. Clean ISBN, ASIN and provider id input as it is typed or pasted, and apply the same shared rule in the book metadata and Book Dock DTOs so every client, including released iOS builds, gets it. Provider ids keep their punctuation because Google ids and slugs use it, and an over-long value is kept whole so validation still names the field. Closes #1624
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { normalizeIdentifierInput, type IdentifierInputKind } from '@bookorbit/types'
|
||||
|
||||
const props = defineProps<{ kind: IdentifierInputKind; modelValue?: string | null }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
function handleInput(event: Event) {
|
||||
if ((event as InputEvent).isComposing) return
|
||||
commit(event.target as HTMLInputElement)
|
||||
}
|
||||
|
||||
function handleCompositionEnd(event: CompositionEvent) {
|
||||
commit(event.target as HTMLInputElement)
|
||||
}
|
||||
|
||||
// Cleans in place rather than on save, so a pasted identifier is never cut by a length limit and
|
||||
// the field shows exactly what will be stored. The caret stays after the same typed character.
|
||||
function commit(input: HTMLInputElement) {
|
||||
const raw = input.value
|
||||
const normalized = normalizeIdentifierInput(props.kind, raw)
|
||||
if (normalized !== raw) {
|
||||
const caret = input.selectionStart
|
||||
input.value = normalized
|
||||
if (caret !== null && input.ownerDocument.activeElement === input) {
|
||||
const nextCaret = Math.min(normalizeIdentifierInput(props.kind, raw.slice(0, caret)).length, normalized.length)
|
||||
input.setSelectionRange(nextCaret, nextCaret)
|
||||
}
|
||||
}
|
||||
if (normalized !== (props.modelValue ?? '')) emit('update:modelValue', normalized)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:value="modelValue ?? ''"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@input="handleInput"
|
||||
@compositionend="handleCompositionEnd"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IdentifierInputKind } from '@bookorbit/types'
|
||||
import IdentifierInput from '../IdentifierInput.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
})
|
||||
|
||||
function mountInput(kind: IdentifierInputKind, modelValue: string | null = null, attrs: Record<string, unknown> = {}) {
|
||||
wrapper = mount(IdentifierInput, {
|
||||
props: { kind, modelValue, 'onUpdate:modelValue': (value: string) => wrapper!.setProps({ modelValue: value }) },
|
||||
attrs,
|
||||
attachTo: document.body,
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function field(target: VueWrapper) {
|
||||
return target.get<HTMLInputElement>('input')
|
||||
}
|
||||
|
||||
function emitted(target: VueWrapper): string[] {
|
||||
return (target.emitted('update:modelValue') ?? []).map(([value]) => value as string)
|
||||
}
|
||||
|
||||
async function typeAt(target: VueWrapper, value: string, caret: number) {
|
||||
const input = field(target).element
|
||||
input.focus()
|
||||
input.value = value
|
||||
input.setSelectionRange(caret, caret)
|
||||
await field(target).trigger('input')
|
||||
return input
|
||||
}
|
||||
|
||||
describe('IdentifierInput', () => {
|
||||
it('renders the bound value, and an empty field for null', async () => {
|
||||
const target = mountInput('isbn', '9783426226223')
|
||||
expect(field(target).element.value).toBe('9783426226223')
|
||||
|
||||
await target.setProps({ modelValue: null })
|
||||
expect(field(target).element.value).toBe('')
|
||||
})
|
||||
|
||||
it('has no length limit that could cut a pasted identifier', () => {
|
||||
const target = mountInput('isbn')
|
||||
expect(field(target).attributes('maxlength')).toBeUndefined()
|
||||
expect(field(target).attributes('type')).toBe('text')
|
||||
})
|
||||
|
||||
it('cleans a pasted hyphenated ISBN instead of truncating it', async () => {
|
||||
const target = mountInput('isbn')
|
||||
|
||||
await field(target).setValue('978-3426226223')
|
||||
|
||||
expect(field(target).element.value).toBe('9783426226223')
|
||||
expect(emitted(target)).toEqual(['9783426226223'])
|
||||
})
|
||||
|
||||
it('cleans an ISBN copied with its Amazon label and invisible characters', async () => {
|
||||
const target = mountInput('isbn')
|
||||
|
||||
await field(target).setValue('\u200BISBN-13 : 978-3426226223\u200E')
|
||||
|
||||
expect(field(target).element.value).toBe('9783426226223')
|
||||
expect(emitted(target)).toEqual(['9783426226223'])
|
||||
})
|
||||
|
||||
it('removes the invisible character copied in front of an ASIN', async () => {
|
||||
const target = mountInput('asin')
|
||||
|
||||
await field(target).setValue('\u200Bb00x47zvxm')
|
||||
|
||||
expect(field(target).element.value).toBe('B00X47ZVXM')
|
||||
expect(emitted(target)).toEqual(['B00X47ZVXM'])
|
||||
})
|
||||
|
||||
it('keeps the punctuation of other provider ids while dropping whitespace', async () => {
|
||||
const target = mountInput('provider-id')
|
||||
|
||||
await field(target).setValue(' zyTC-Al_FPjgYC\uFEFF')
|
||||
|
||||
expect(field(target).element.value).toBe('zyTC-Al_FPjgYC')
|
||||
expect(emitted(target)).toEqual(['zyTC-Al_FPjgYC'])
|
||||
})
|
||||
|
||||
it('shows an over-long value in full so validation can name it', async () => {
|
||||
const target = mountInput('isbn')
|
||||
|
||||
await field(target).setValue('978-3426226223-0000')
|
||||
|
||||
expect(field(target).element.value).toBe('97834262262230000')
|
||||
expect(emitted(target)).toEqual(['97834262262230000'])
|
||||
})
|
||||
|
||||
it('swallows a typed separator without emitting an unchanged value', async () => {
|
||||
const target = mountInput('isbn', '978')
|
||||
|
||||
const input = await typeAt(target, '978-', 4)
|
||||
|
||||
expect(input.value).toBe('978')
|
||||
expect(input.selectionStart).toBe(3)
|
||||
expect(emitted(target)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the caret after the same character when text is cleaned mid-value', async () => {
|
||||
const target = mountInput('isbn', '978226223')
|
||||
|
||||
const input = await typeAt(target, '978-3426-226223', 8)
|
||||
|
||||
expect(input.value).toBe('9783426226223')
|
||||
expect(input.selectionStart).toBe(7)
|
||||
expect(input.selectionEnd).toBe(7)
|
||||
})
|
||||
|
||||
it('leaves the caret alone when nothing needed cleaning', async () => {
|
||||
const target = mountInput('isbn', '978')
|
||||
|
||||
const input = await typeAt(target, '9783', 4)
|
||||
|
||||
expect(input.selectionStart).toBe(4)
|
||||
expect(emitted(target)).toEqual(['9783'])
|
||||
})
|
||||
|
||||
it('emits an empty string when an existing value is cleared', async () => {
|
||||
const target = mountInput('isbn', '9783426226223')
|
||||
|
||||
await field(target).setValue('')
|
||||
|
||||
expect(emitted(target)).toEqual([''])
|
||||
})
|
||||
|
||||
it('does not turn an empty field into an edit when only separators were typed', async () => {
|
||||
const target = mountInput('isbn', null)
|
||||
|
||||
await field(target).setValue('-')
|
||||
|
||||
expect(field(target).element.value).toBe('')
|
||||
expect(emitted(target)).toEqual([])
|
||||
})
|
||||
|
||||
it('waits for an input method composition to finish before cleaning', async () => {
|
||||
const target = mountInput('isbn')
|
||||
const input = field(target).element
|
||||
|
||||
input.value = '\uFF19\uFF17\uFF18\uFF0D'
|
||||
input.dispatchEvent(new InputEvent('input', { isComposing: true }))
|
||||
expect(input.value).toBe('\uFF19\uFF17\uFF18\uFF0D')
|
||||
expect(emitted(target)).toEqual([])
|
||||
|
||||
input.dispatchEvent(new CompositionEvent('compositionend'))
|
||||
expect(input.value).toBe('978')
|
||||
expect(emitted(target)).toEqual(['978'])
|
||||
})
|
||||
|
||||
it('reflects a value set from outside, such as an applied metadata match', async () => {
|
||||
const target = mountInput('isbn', '')
|
||||
|
||||
await target.setProps({ modelValue: '9780306406157' })
|
||||
|
||||
expect(field(target).element.value).toBe('9780306406157')
|
||||
})
|
||||
|
||||
it('passes attributes and listeners through to the input', async () => {
|
||||
const onInput = vi.fn<(event: Event) => void>()
|
||||
const target = mountInput('isbn', null, { id: 'isbn-field', class: 'font-mono', disabled: true, onInput })
|
||||
|
||||
expect(field(target).attributes('id')).toBe('isbn-field')
|
||||
expect(field(target).classes()).toContain('font-mono')
|
||||
expect(field(target).element.disabled).toBe(true)
|
||||
|
||||
field(target).element.disabled = false
|
||||
await field(target).setValue('978-0')
|
||||
|
||||
expect(onInput).toHaveBeenCalledTimes(1)
|
||||
expect(emitted(target)).toEqual(['9780'])
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,9 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { X, Loader2 } from '@lucide/vue'
|
||||
import { isValidSeriesIndex, type BookDockBulkEditResult, type BookDockMetadata } from '@bookorbit/types'
|
||||
import { isValidSeriesIndex, type BookDockBulkEditResult, type BookDockMetadata, type IdentifierInputKind } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import IdentifierInput from '@/components/ui/IdentifierInput.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -29,19 +30,22 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const result = ref<BookDockBulkEditResult | null>(null)
|
||||
|
||||
const FIELDS = computed<{ key: keyof BookDockMetadata; label: string; type: 'text' | 'number' | 'array' }[]>(() => [
|
||||
const FIELDS = computed<{ key: keyof BookDockMetadata; label: string; type: 'text' | 'number' | 'array'; identifier?: IdentifierInputKind }[]>(() => [
|
||||
{ key: 'title', label: t('bookDock.field.title'), type: 'text' },
|
||||
{ key: 'authors', label: t('bookDock.field.authors'), type: 'array' },
|
||||
{ key: 'publisher', label: t('bookDock.field.publisher'), type: 'text' },
|
||||
{ key: 'publishedYear', label: t('bookDock.field.year'), type: 'number' },
|
||||
{ key: 'language', label: t('bookDock.field.language'), type: 'text' },
|
||||
{ key: 'isbn13', label: t('bookDock.field.isbn13'), type: 'text' },
|
||||
{ key: 'isbn10', label: t('bookDock.field.isbn10'), type: 'text' },
|
||||
{ key: 'isbn13', label: t('bookDock.field.isbn13'), type: 'text', identifier: 'isbn' },
|
||||
{ key: 'isbn10', label: t('bookDock.field.isbn10'), type: 'text', identifier: 'isbn' },
|
||||
{ key: 'seriesName', label: t('bookDock.field.series'), type: 'text' },
|
||||
{ key: 'seriesIndex', label: t('bookDock.field.seriesIndex'), type: 'text' },
|
||||
{ key: 'genres', label: t('bookDock.field.genres'), type: 'array' },
|
||||
])
|
||||
|
||||
const FIELD_INPUT_CLASS =
|
||||
'w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring disabled:opacity-40'
|
||||
|
||||
const enabledFields = reactive<Set<string>>(new Set())
|
||||
const values = reactive<Record<string, string>>({})
|
||||
const mergeArrays = ref(true)
|
||||
@@ -141,11 +145,19 @@ function handleClose() {
|
||||
/>
|
||||
<label class="flex-1">
|
||||
<span class="text-xs font-medium text-muted-foreground block mb-0.5">{{ f.label }}</span>
|
||||
<IdentifierInput
|
||||
v-if="f.identifier"
|
||||
v-model="values[f.key]"
|
||||
:kind="f.identifier"
|
||||
:disabled="!enabledFields.has(f.key)"
|
||||
:class="FIELD_INPUT_CLASS"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="values[f.key]"
|
||||
:disabled="!enabledFields.has(f.key)"
|
||||
:placeholder="f.type === 'array' ? t('bookDock.commaSeparated') : ''"
|
||||
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring disabled:opacity-40"
|
||||
:class="FIELD_INPUT_CLASS"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SERIES_INDEX_MAX_LENGTH,
|
||||
} from '@bookorbit/types'
|
||||
import BookDockStatusBadge from './BookDockStatusBadge.vue'
|
||||
import IdentifierInput from '@/components/ui/IdentifierInput.vue'
|
||||
import MetadataMatchWorkspace from '@/features/book/components/metadata-match/MetadataMatchWorkspace.vue'
|
||||
import type { MetadataQuery } from '@/features/book/components/metadata-match/MetadataMatchQuery.vue'
|
||||
import { useBookDockDetail } from '../composables/useBookDockDetail'
|
||||
@@ -574,16 +575,18 @@ onMounted(() => {
|
||||
</label>
|
||||
<label>
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.field.isbn13') }}</span>
|
||||
<input
|
||||
<IdentifierInput
|
||||
v-model="form.isbn13"
|
||||
kind="isbn"
|
||||
class="mt-1 w-full h-8 rounded-lg border border-input bg-background px-3 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
||||
@input="onFieldChange"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.field.isbn10') }}</span>
|
||||
<input
|
||||
<IdentifierInput
|
||||
v-model="form.isbn10"
|
||||
kind="isbn"
|
||||
class="mt-1 w-full h-8 rounded-lg border border-input bg-background px-3 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
||||
@input="onFieldChange"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { i18n } from '@/i18n'
|
||||
import BookDockBulkEditDialog from '../BookDockBulkEditDialog.vue'
|
||||
|
||||
const api = vi.fn<(...args: unknown[]) => Promise<Response>>()
|
||||
vi.mock('@/lib/api', () => ({ api: (...args: unknown[]) => api(...args) }))
|
||||
|
||||
let wrapper: VueWrapper
|
||||
|
||||
function mountDialog() {
|
||||
wrapper = mount(BookDockBulkEditDialog, {
|
||||
props: { selectionPayload: { fileIds: [3, 4] }, selectionCount: 2 },
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
}
|
||||
|
||||
function fieldRow(label: string) {
|
||||
return wrapper.findAll('div.flex.items-center.gap-2').find((row) => row.find('label span').exists() && row.get('label span').text() === label)!
|
||||
}
|
||||
|
||||
// The stubbed Teleport re-creates its content on every update, so elements are looked up afresh.
|
||||
function fieldInput(label: string) {
|
||||
return fieldRow(label).get<HTMLInputElement>('label input')
|
||||
}
|
||||
|
||||
async function enableAndType(label: string, value: string) {
|
||||
await fieldRow(label).get('input[type="checkbox"]').trigger('change')
|
||||
await fieldInput(label).setValue(value)
|
||||
}
|
||||
|
||||
function applyButton() {
|
||||
return wrapper.findAll('button').find((button) => button.text().trim() === 'Apply')!
|
||||
}
|
||||
|
||||
function sentBody() {
|
||||
const [, init] = api.mock.calls[0] as [string, RequestInit]
|
||||
return JSON.parse(init.body as string)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
api.mockReset().mockResolvedValue({ ok: true, json: async () => ({ total: 2, updated: 2, failed: 0 }) } as Response)
|
||||
})
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
describe('BookDockBulkEditDialog', () => {
|
||||
// Issue #1624: a hyphenated ISBN used to reach the server and fail its 13 character limit.
|
||||
it('applies pasted ISBNs cleaned instead of sending values the server would refuse', async () => {
|
||||
mountDialog()
|
||||
|
||||
await enableAndType('ISBN-13', '978-3426226223')
|
||||
await enableAndType('ISBN-10', '\u200B0-8044-2957-x')
|
||||
expect(fieldInput('ISBN-13').element.value).toBe('9783426226223')
|
||||
expect(fieldInput('ISBN-10').element.value).toBe('080442957X')
|
||||
|
||||
await applyButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/v1/book-dock/files/bulk-edit', expect.objectContaining({ method: 'POST' }))
|
||||
expect(sentBody()).toMatchObject({
|
||||
fileIds: [3, 4],
|
||||
fields: { isbn13: '9783426226223', isbn10: '080442957X' },
|
||||
enabledFields: ['isbn13', 'isbn10'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an identifier field disabled until it is enabled', async () => {
|
||||
mountDialog()
|
||||
|
||||
expect(fieldInput('ISBN-13').element.disabled).toBe(true)
|
||||
|
||||
await fieldRow('ISBN-13').get('input[type="checkbox"]').trigger('change')
|
||||
expect(fieldInput('ISBN-13').element.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves text fields that are not identifiers untouched', async () => {
|
||||
mountDialog()
|
||||
|
||||
await enableAndType('Publisher', ' Droemer - Knaur ')
|
||||
await applyButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sentBody().fields).toEqual({ publisher: ' Droemer - Knaur ' })
|
||||
})
|
||||
})
|
||||
@@ -111,6 +111,7 @@ function mountSheet(file: BookDockFile) {
|
||||
MetadataMatchWorkspace: WorkspaceStub,
|
||||
BookDockStatusBadge: true,
|
||||
BookDestinationFields: false,
|
||||
IdentifierInput: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -207,6 +208,28 @@ describe('BookDockFileSheet', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('autosaves pasted ISBNs cleaned instead of sending values the server would refuse', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const wrapper = mountSheet(makeFile())
|
||||
const input = (name: string) =>
|
||||
wrapper
|
||||
.findAll('label')
|
||||
.find((item) => item.text().includes(name))!
|
||||
.get<HTMLInputElement>('input')
|
||||
|
||||
await input('ISBN-13').setValue('978-3426226223')
|
||||
await input('ISBN-10').setValue('\u200B0-8044-2957-x')
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(input('ISBN-13').element.value).toBe('9783426226223')
|
||||
expect(input('ISBN-10').element.value).toBe('080442957X')
|
||||
expect(mocks.saveMetadata).toHaveBeenLastCalledWith(1, expect.objectContaining({ isbn13: '9783426226223', isbn10: '080442957X' }))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows an error and does not autosave a malformed series index', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { BookCommunityRating, BookDetail, BookMetadataLockField, CoverMediu
|
||||
import {
|
||||
BOOK_FILE_WRITE_FIELD_LABELS,
|
||||
FORMAT_TO_GROUP,
|
||||
IDENTIFIER_INPUT_KIND_BY_FIELD,
|
||||
getPrimaryBookFile,
|
||||
isValidSeriesIndex,
|
||||
parseSeriesIndex,
|
||||
@@ -30,6 +31,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { api } from '@/lib/api'
|
||||
import { metadataScoreColor } from '@/lib/metadata-score-color'
|
||||
import ChipInput from '@/components/ui/ChipInput.vue'
|
||||
import IdentifierInput from '@/components/ui/IdentifierInput.vue'
|
||||
import CoverEditorPanel from './CoverEditorPanel.vue'
|
||||
import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
|
||||
import MetadataFieldLabel from './MetadataFieldLabel.vue'
|
||||
@@ -1257,7 +1259,7 @@ function handleCoverChanged(medium: CoverMedium | null) {
|
||||
:is-updating="isUpdatingLock"
|
||||
@toggle="handleLockToggle"
|
||||
>
|
||||
<input v-model="form.isbn13" maxlength="13" :class="controlClass(!form.isbn13, true)" :disabled="isLocked('isbn13')" />
|
||||
<IdentifierInput v-model="form.isbn13" kind="isbn" :class="controlClass(!form.isbn13, true)" :disabled="isLocked('isbn13')" />
|
||||
</MetadataFieldLabel>
|
||||
|
||||
<MetadataFieldLabel
|
||||
@@ -1268,7 +1270,7 @@ function handleCoverChanged(medium: CoverMedium | null) {
|
||||
:is-updating="isUpdatingLock"
|
||||
@toggle="handleLockToggle"
|
||||
>
|
||||
<input v-model="form.isbn10" maxlength="10" :class="controlClass(!form.isbn10, true)" :disabled="isLocked('isbn10')" />
|
||||
<IdentifierInput v-model="form.isbn10" kind="isbn" :class="controlClass(!form.isbn10, true)" :disabled="isLocked('isbn10')" />
|
||||
</MetadataFieldLabel>
|
||||
|
||||
<MetadataFieldLabel
|
||||
@@ -1368,9 +1370,10 @@ function handleCoverChanged(medium: CoverMedium | null) {
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<input
|
||||
<IdentifierInput
|
||||
:id="`provider-id-${field}`"
|
||||
v-model="form[field]"
|
||||
:kind="IDENTIFIER_INPUT_KIND_BY_FIELD[field]"
|
||||
class="h-8 min-w-0 flex-1 rounded-xs border border-transparent bg-transparent px-1.5 font-mono text-xs transition-shadow outline-none hover:border-input focus:border-input focus:bg-background focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 sm:h-7"
|
||||
:disabled="isLocked(field)"
|
||||
/>
|
||||
|
||||
@@ -225,6 +225,42 @@ describe('EditMetadataTab cover tiles', () => {
|
||||
expect(body.metadata).toMatchObject({ isbn10: '0306406152', isbn13: '9780306406157' })
|
||||
})
|
||||
|
||||
// Issue #1624: identifiers pasted from Amazon carry hyphens and invisible characters.
|
||||
it('saves pasted identifiers cleaned instead of truncated', async () => {
|
||||
vi.mocked(api).mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'PATCH') return json({ book: savedBook, write: null, libraryAutoWriteEnabled: false })
|
||||
if (String(url).includes('/metadata-fetch/providers')) return json([{ key: 'amazon' }, { key: 'google' }, { key: 'audible' }])
|
||||
return json({})
|
||||
})
|
||||
const wrapper = mountTab(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
const isbn13 = wrapper.get<HTMLInputElement>('[data-field="isbn13"] input')
|
||||
const isbn10 = wrapper.get<HTMLInputElement>('[data-field="isbn10"] input')
|
||||
expect(isbn13.attributes('maxlength')).toBeUndefined()
|
||||
expect(isbn10.attributes('maxlength')).toBeUndefined()
|
||||
|
||||
await isbn13.setValue('978-3426226223')
|
||||
await isbn10.setValue('\u200B0-8044-2957-x')
|
||||
await wrapper.get('#provider-id-amazonId').setValue('\u200BB00X47ZVXM')
|
||||
await wrapper.get('#provider-id-audibleId').setValue(' b002v0qk4c ')
|
||||
await wrapper.get('#provider-id-googleBooksId').setValue('zyTC-Al_FPjgYC\u200E')
|
||||
|
||||
expect(isbn13.element.value).toBe('9783426226223')
|
||||
expect(isbn10.element.value).toBe('080442957X')
|
||||
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
const body = JSON.parse(patchCalls()[0]![1]!.body as string)
|
||||
expect(body.metadata).toMatchObject({
|
||||
isbn13: '9783426226223',
|
||||
isbn10: '080442957X',
|
||||
amazonId: 'B00X47ZVXM',
|
||||
audibleId: 'B002V0QK4C',
|
||||
googleBooksId: 'zyTC-Al_FPjgYC',
|
||||
})
|
||||
})
|
||||
|
||||
it('warns when auto-fill used saved results for a provider that was unavailable', async () => {
|
||||
vi.mocked(toast.warning).mockClear()
|
||||
vi.mocked(api).mockImplementation(async (url) => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* How a hand-entered identifier is cleaned before it is stored. An identifier copied from a store
|
||||
* page carries its print formatting (hyphens, spaces) and often an invisible character such as a
|
||||
* zero-width space or a bidi mark. None of these belong to the identifier, and a field that looks
|
||||
* right while holding one is very hard to diagnose. Cleaning never truncates: an over-long result
|
||||
* stays over-long so validation can name it.
|
||||
*/
|
||||
export type IdentifierInputKind = "isbn" | "asin" | "provider-id";
|
||||
|
||||
/**
|
||||
* The cleaning rule for each editable identifier field. Only ISBNs and ASINs have a fixed alphabet;
|
||||
* every other provider id keeps its punctuation, because Google Books ids and Hardcover and Kobo
|
||||
* slugs legitimately contain `-` and `_`.
|
||||
*/
|
||||
export const IDENTIFIER_INPUT_KIND_BY_FIELD = {
|
||||
isbn10: "isbn",
|
||||
isbn13: "isbn",
|
||||
amazonId: "asin",
|
||||
audibleId: "asin",
|
||||
googleBooksId: "provider-id",
|
||||
goodreadsId: "provider-id",
|
||||
hardcoverId: "provider-id",
|
||||
hardcoverEditionId: "provider-id",
|
||||
openLibraryId: "provider-id",
|
||||
itunesId: "provider-id",
|
||||
librofmId: "provider-id",
|
||||
koboId: "provider-id",
|
||||
comicvineId: "provider-id",
|
||||
ranobedbId: "provider-id",
|
||||
lubimyczytacId: "provider-id",
|
||||
aladinId: "provider-id",
|
||||
} as const satisfies Record<string, IdentifierInputKind>;
|
||||
|
||||
export type IdentifierInputField = keyof typeof IDENTIFIER_INPUT_KIND_BY_FIELD;
|
||||
|
||||
// Whitespace plus Unicode control (Cc) and format (Cf) characters: zero-width spaces and joiners,
|
||||
// bidi marks, the byte-order mark and the soft hyphen.
|
||||
const INVISIBLE_OR_WHITESPACE = /[\s\p{Cc}\p{Cf}]/gu;
|
||||
|
||||
// Amazon prints "ISBN-13 : 978-...". Without this the label's digits survive the digit filter and
|
||||
// prefix the real number. The colon is required so an ISBN-10 starting with 10 or 13 is never cut.
|
||||
const NUMBERED_ISBN_LABEL = /^ISBN\p{Pd}?1[03]:/iu;
|
||||
|
||||
/** Keeps the digits and the ISBN-10 check character `X`, so `978-3426226223` becomes `9783426226223`. */
|
||||
export function normalizeIsbnInput(value: string): string {
|
||||
return value
|
||||
.normalize("NFKC")
|
||||
.replace(INVISIBLE_OR_WHITESPACE, "")
|
||||
.replace(NUMBERED_ISBN_LABEL, "")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9X]/g, "");
|
||||
}
|
||||
|
||||
/** ASINs are upper-case alphanumerics; anything else is formatting or an invisible character. */
|
||||
export function normalizeAsinInput(value: string): string {
|
||||
return value
|
||||
.normalize("NFKC")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, "");
|
||||
}
|
||||
|
||||
/** Removes only whitespace and invisible characters, which no provider id contains. */
|
||||
export function normalizeProviderIdInput(value: string): string {
|
||||
return value.replace(INVISIBLE_OR_WHITESPACE, "");
|
||||
}
|
||||
|
||||
export function normalizeIdentifierInput(kind: IdentifierInputKind, value: string): string {
|
||||
switch (kind) {
|
||||
case "isbn":
|
||||
return normalizeIsbnInput(value);
|
||||
case "asin":
|
||||
return normalizeAsinInput(value);
|
||||
case "provider-id":
|
||||
return normalizeProviderIdInput(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function identifierInputKindForField(field: string): IdentifierInputKind | null {
|
||||
return Object.prototype.hasOwnProperty.call(IDENTIFIER_INPUT_KIND_BY_FIELD, field)
|
||||
? IDENTIFIER_INPUT_KIND_BY_FIELD[field as IdentifierInputField]
|
||||
: null;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export * from "./metadata-preferences";
|
||||
export * from "./custom-metadata";
|
||||
export * from "./custom-icon";
|
||||
export * from "./metadata-lock";
|
||||
export * from "./identifier-input";
|
||||
export * from "./dashboard";
|
||||
export * from "./book";
|
||||
export * from "./book-duplicates";
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IDENTIFIER_INPUT_KIND_BY_FIELD,
|
||||
identifierInputKindForField,
|
||||
normalizeAsinInput,
|
||||
normalizeIdentifierInput,
|
||||
normalizeIsbnInput,
|
||||
normalizeProviderIdInput,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { PROVIDER_ID_MAX_LENGTHS } from '../utils/provider-id.utils';
|
||||
import { NormalizeIdentifier } from './normalize-identifier.decorator';
|
||||
|
||||
const INVISIBLE_CHARACTERS = {
|
||||
'zero-width space': '\u200B',
|
||||
'zero-width non-joiner': '\u200C',
|
||||
'zero-width joiner': '\u200D',
|
||||
'left-to-right mark': '\u200E',
|
||||
'right-to-left mark': '\u200F',
|
||||
'left-to-right embedding': '\u202A',
|
||||
'pop directional formatting': '\u202C',
|
||||
'left-to-right isolate': '\u2066',
|
||||
'word joiner': '\u2060',
|
||||
'byte-order mark': '\uFEFF',
|
||||
'soft hyphen': '\u00AD',
|
||||
'null character': '\u0000',
|
||||
'delete character': '\u007F',
|
||||
};
|
||||
|
||||
const WHITESPACE_CHARACTERS = {
|
||||
space: ' ',
|
||||
tab: '\t',
|
||||
'line feed': '\n',
|
||||
'carriage return': '\r',
|
||||
'no-break space': '\u00A0',
|
||||
'thin space': '\u2009',
|
||||
'narrow no-break space': '\u202F',
|
||||
'ideographic space': '\u3000',
|
||||
'line separator': '\u2028',
|
||||
};
|
||||
|
||||
describe('normalizeIsbnInput', () => {
|
||||
it('removes the hyphens Amazon prints in an ISBN-13', () => {
|
||||
expect(normalizeIsbnInput('978-3426226223')).toBe('9783426226223');
|
||||
expect(normalizeIsbnInput('978-3-426-22622-3')).toBe('9783426226223');
|
||||
});
|
||||
|
||||
it('removes every dash and separator an ISBN is printed with', () => {
|
||||
for (const separator of ['-', '\u2010', '\u2011', '\u2012', '\u2013', '\u2212', '\uFE63', '\uFF0D', '.', '/', '_', '\u00B7']) {
|
||||
expect({ separator, value: normalizeIsbnInput(`978${separator}0${separator}306${separator}40615${separator}7`) }).toEqual({
|
||||
separator,
|
||||
value: '9780306406157',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Object.entries(INVISIBLE_CHARACTERS))('removes a %s wherever it appears', (_name, character) => {
|
||||
expect(normalizeIsbnInput(`${character}978030640615${character}7${character}`)).toBe('9780306406157');
|
||||
});
|
||||
|
||||
it.each(Object.entries(WHITESPACE_CHARACTERS))('removes a %s wherever it appears', (_name, character) => {
|
||||
expect(normalizeIsbnInput(`${character}978 0306${character}406157${character}`)).toBe('9780306406157');
|
||||
});
|
||||
|
||||
it('upper-cases the ISBN-10 check character', () => {
|
||||
expect(normalizeIsbnInput('0-8044-2957-x')).toBe('080442957X');
|
||||
expect(normalizeIsbnInput('080442957X')).toBe('080442957X');
|
||||
});
|
||||
|
||||
it('folds full-width digits, hyphens and check characters to ASCII', () => {
|
||||
expect(normalizeIsbnInput('\uFF19\uFF17\uFF18\uFF0D\uFF14\uFF10\uFF18\uFF18\uFF10\uFF16\uFF16\uFF13\uFF17\uFF16')).toBe('9784088066376');
|
||||
expect(normalizeIsbnInput('080442957\uFF58')).toBe('080442957X');
|
||||
});
|
||||
|
||||
it('drops a numbered ISBN label together with its digits', () => {
|
||||
expect(normalizeIsbnInput('ISBN-13 : 978-3426226223')).toBe('9783426226223');
|
||||
expect(normalizeIsbnInput('ISBN13: 9783426226223')).toBe('9783426226223');
|
||||
expect(normalizeIsbnInput('ISBN 13: 9783426226223')).toBe('9783426226223');
|
||||
expect(normalizeIsbnInput('isbn-10: 0-306-40615-2')).toBe('0306406152');
|
||||
expect(normalizeIsbnInput('ISBN\u201013\uFF1A978-3426226223')).toBe('9783426226223');
|
||||
expect(normalizeIsbnInput('\u200BISBN-13 : 978-3426226223')).toBe('9783426226223');
|
||||
});
|
||||
|
||||
it('drops a bare ISBN label', () => {
|
||||
expect(normalizeIsbnInput('ISBN 978-3-426-22622-3')).toBe('9783426226223');
|
||||
expect(normalizeIsbnInput('ISBN: 0-306-40615-2')).toBe('0306406152');
|
||||
});
|
||||
|
||||
it('keeps the leading digits of an ISBN-10 that starts with 10 or 13', () => {
|
||||
expect(normalizeIsbnInput('1302123456')).toBe('1302123456');
|
||||
expect(normalizeIsbnInput('13-02123456')).toBe('1302123456');
|
||||
expect(normalizeIsbnInput('ISBN 1302123456')).toBe('1302123456');
|
||||
expect(normalizeIsbnInput('ISBN: 1034567890')).toBe('1034567890');
|
||||
expect(normalizeIsbnInput('ISBN-1302123456')).toBe('1302123456');
|
||||
});
|
||||
|
||||
it('never truncates an over-long value', () => {
|
||||
expect(normalizeIsbnInput('978-3426226223-0000')).toBe('97834262262230000');
|
||||
expect(normalizeIsbnInput('9'.repeat(40))).toBe('9'.repeat(40));
|
||||
});
|
||||
|
||||
it('returns an empty string when nothing of the identifier remains', () => {
|
||||
expect(normalizeIsbnInput('')).toBe('');
|
||||
expect(normalizeIsbnInput(' \u200B\uFEFF\t')).toBe('');
|
||||
expect(normalizeIsbnInput('---')).toBe('');
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
for (const value of ['978-3426226223', '\u200BISBN-13 : 978-3426226223', '0-8044-2957-x', 'ISBN 1302123456', '']) {
|
||||
const once = normalizeIsbnInput(value);
|
||||
expect(normalizeIsbnInput(once)).toBe(once);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAsinInput', () => {
|
||||
it('removes the zero-width space Amazon copies in front of an ASIN', () => {
|
||||
expect(normalizeAsinInput('\u200BB00X47ZVXM')).toBe('B00X47ZVXM');
|
||||
});
|
||||
|
||||
it.each(Object.entries(INVISIBLE_CHARACTERS))('removes a %s wherever it appears', (_name, character) => {
|
||||
expect(normalizeAsinInput(`${character}B00X4${character}7ZVXM${character}`)).toBe('B00X47ZVXM');
|
||||
});
|
||||
|
||||
it.each(Object.entries(WHITESPACE_CHARACTERS))('removes a %s wherever it appears', (_name, character) => {
|
||||
expect(normalizeAsinInput(`${character}B00X4${character}7ZVXM${character}`)).toBe('B00X47ZVXM');
|
||||
});
|
||||
|
||||
it('removes separators and upper-cases the result', () => {
|
||||
expect(normalizeAsinInput('b00x-47zv-xm')).toBe('B00X47ZVXM');
|
||||
expect(normalizeAsinInput('B00X47ZVXM.')).toBe('B00X47ZVXM');
|
||||
});
|
||||
|
||||
it('keeps an ISBN-10 shaped ASIN intact, including its check character', () => {
|
||||
expect(normalizeAsinInput('0-8044-2957-x')).toBe('080442957X');
|
||||
});
|
||||
|
||||
it('folds full-width characters to ASCII', () => {
|
||||
expect(normalizeAsinInput('\uFF22\uFF10\uFF10\uFF58\uFF14\uFF17\uFF3A\uFF36\uFF38\uFF2D')).toBe('B00X47ZVXM');
|
||||
});
|
||||
|
||||
it('never truncates an over-long value', () => {
|
||||
expect(normalizeAsinInput('B00X47ZVXM-B00X47ZVXM')).toBe('B00X47ZVXMB00X47ZVXM');
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
for (const value of ['\u200Bb00x47zvxm ', 'B00X47ZVXM', '']) {
|
||||
const once = normalizeAsinInput(value);
|
||||
expect(normalizeAsinInput(once)).toBe(once);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeProviderIdInput', () => {
|
||||
it.each(Object.entries(INVISIBLE_CHARACTERS))('removes a %s wherever it appears', (_name, character) => {
|
||||
expect(normalizeProviderIdInput(`${character}zyTC${character}AlFPjgYC${character}`)).toBe('zyTCAlFPjgYC');
|
||||
});
|
||||
|
||||
it.each(Object.entries(WHITESPACE_CHARACTERS))('removes a %s wherever it appears', (_name, character) => {
|
||||
expect(normalizeProviderIdInput(`${character}12345${character}678${character}`)).toBe('12345678');
|
||||
});
|
||||
|
||||
it('keeps the punctuation and case that provider ids legitimately use', () => {
|
||||
expect(normalizeProviderIdInput('zyTC-Al_FPjgYC')).toBe('zyTC-Al_FPjgYC');
|
||||
expect(normalizeProviderIdInput('the-name-of-the-wind')).toBe('the-name-of-the-wind');
|
||||
expect(normalizeProviderIdInput('OL7353617M')).toBe('OL7353617M');
|
||||
expect(normalizeProviderIdInput('12345.The_Book')).toBe('12345.The_Book');
|
||||
});
|
||||
|
||||
it('never truncates an over-long value', () => {
|
||||
expect(normalizeProviderIdInput(` ${'a'.repeat(600)} `)).toBe('a'.repeat(600));
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeIdentifierInput', () => {
|
||||
it('applies the rule for the requested kind', () => {
|
||||
expect(normalizeIdentifierInput('isbn', ' 978-3426226223 ')).toBe('9783426226223');
|
||||
expect(normalizeIdentifierInput('asin', '\u200Bb00x47zvxm')).toBe('B00X47ZVXM');
|
||||
expect(normalizeIdentifierInput('provider-id', '\u200Bzy-TC ')).toBe('zy-TC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('identifierInputKindForField', () => {
|
||||
it('cleans ISBNs as ISBNs and Amazon and Audible ids as ASINs', () => {
|
||||
expect(identifierInputKindForField('isbn10')).toBe('isbn');
|
||||
expect(identifierInputKindForField('isbn13')).toBe('isbn');
|
||||
expect(identifierInputKindForField('amazonId')).toBe('asin');
|
||||
expect(identifierInputKindForField('audibleId')).toBe('asin');
|
||||
});
|
||||
|
||||
// A provider id column without a rule would reach the DTO decorator and throw at boot.
|
||||
it('has a rule for every stored provider id', () => {
|
||||
for (const field of Object.keys(PROVIDER_ID_MAX_LENGTHS)) {
|
||||
expect({ field, kind: identifierInputKindForField(field) }).toEqual({ field, kind: expect.any(String) });
|
||||
}
|
||||
expect(Object.keys(IDENTIFIER_INPUT_KIND_BY_FIELD).sort()).toEqual([...Object.keys(PROVIDER_ID_MAX_LENGTHS), 'isbn10', 'isbn13'].sort());
|
||||
});
|
||||
|
||||
it('returns null for fields that are not identifiers, including inherited object keys', () => {
|
||||
for (const field of ['title', 'publisher', 'toString', 'constructor', '__proto__', 'hasOwnProperty', '']) {
|
||||
expect({ field, kind: identifierInputKindForField(field) }).toEqual({ field, kind: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('NormalizeIdentifier', () => {
|
||||
class IdentifierProbe {
|
||||
@NormalizeIdentifier() isbn13?: unknown;
|
||||
@NormalizeIdentifier() amazonId?: unknown;
|
||||
@NormalizeIdentifier() googleBooksId?: unknown;
|
||||
}
|
||||
|
||||
it('cleans each string by the rule its property name selects', () => {
|
||||
const probe = plainToInstance(IdentifierProbe, {
|
||||
isbn13: '\u200B978-3426226223',
|
||||
amazonId: '\u200Bb00x47zvxm ',
|
||||
googleBooksId: ' zyTC-Al_FPjgYC\u200E',
|
||||
});
|
||||
|
||||
expect(probe).toEqual({ isbn13: '9783426226223', amazonId: 'B00X47ZVXM', googleBooksId: 'zyTC-Al_FPjgYC' });
|
||||
});
|
||||
|
||||
it('passes non-string values through for the validators to judge', () => {
|
||||
expect(plainToInstance(IdentifierProbe, { isbn13: null, amazonId: 9780306406157, googleBooksId: ['x'] })).toEqual({
|
||||
isbn13: null,
|
||||
amazonId: 9780306406157,
|
||||
googleBooksId: ['x'],
|
||||
});
|
||||
expect(plainToInstance(IdentifierProbe, {}).isbn13).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a property that has no identifier rule', () => {
|
||||
expect(() => NormalizeIdentifier()(Object.prototype, 'title')).toThrow('@NormalizeIdentifier() has no identifier rule for "title"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { identifierInputKindForField, normalizeIdentifierInput } from '@bookorbit/types';
|
||||
|
||||
/**
|
||||
* Cleans a hand-entered identifier before validation, using the rule the client applies as the
|
||||
* user types, so `978-3426226223` is stored as `9783426226223` instead of failing `@MaxLength(13)`.
|
||||
* The rule comes from the property name; non-strings pass through so `@IsString()` still rejects them.
|
||||
*/
|
||||
export function NormalizeIdentifier(): PropertyDecorator {
|
||||
return (target, propertyKey) => {
|
||||
const kind = identifierInputKindForField(String(propertyKey));
|
||||
if (!kind) throw new Error(`@NormalizeIdentifier() has no identifier rule for "${String(propertyKey)}"`);
|
||||
Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? normalizeIdentifierInput(kind, value) : value))(target, propertyKey);
|
||||
};
|
||||
}
|
||||
@@ -64,12 +64,12 @@ describe('BookDock DTO validation', () => {
|
||||
communityRatings: [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }],
|
||||
googleBooksId: 'google-id',
|
||||
goodreadsId: 'goodreads-id',
|
||||
amazonId: 'amazon-id',
|
||||
amazonId: 'B00X47ZVXM',
|
||||
hardcoverId: 'hardcover-id',
|
||||
hardcoverEditionId: 'hardcover-edition-id',
|
||||
openLibraryId: 'OL1W',
|
||||
itunesId: 'itunes-id',
|
||||
audibleId: 'audible-id',
|
||||
audibleId: 'B002V0QK4C',
|
||||
librofmId: 'librofm-id',
|
||||
koboId: 'kobo-id',
|
||||
comicvineId: 'comicvine-id',
|
||||
@@ -154,4 +154,39 @@ describe('BookDock DTO validation', () => {
|
||||
(await errorsFor(BulkEditBookDockDto, { fields: { seriesIndex: 5.1 }, enabledFields: ['seriesIndex'], mergeArrays: false })).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Issue #1624: a hyphenated ISBN used to fail @MaxLength(13) instead of being saved.
|
||||
it('UpdateBookDockFileDto cleans pasted identifiers through the production validation path', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
|
||||
|
||||
await expect(
|
||||
pipe.transform(
|
||||
{
|
||||
selectedMetadata: {
|
||||
isbn13: 'ISBN-13 : 978-3426226223',
|
||||
isbn10: '0 8044 2957 x',
|
||||
amazonId: '\u200BB00X47ZVXM',
|
||||
goodreadsId: ' 12345.The_Book\u200E',
|
||||
},
|
||||
},
|
||||
{ type: 'body', metatype: UpdateBookDockFileDto, data: undefined },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
selectedMetadata: { isbn13: '9783426226223', isbn10: '080442957X', amazonId: 'B00X47ZVXM', goodreadsId: '12345.The_Book' },
|
||||
});
|
||||
});
|
||||
|
||||
it('BulkEditBookDockDto cleans pasted ISBNs and still rejects ones too long to be an ISBN', async () => {
|
||||
const valid = plainToInstance(BulkEditBookDockDto, {
|
||||
fields: { isbn13: '978-3-426-22622-3', isbn10: '\uFEFF0-306-40615-2' },
|
||||
enabledFields: ['isbn13', 'isbn10'],
|
||||
mergeArrays: false,
|
||||
});
|
||||
expect(await validate(valid)).toHaveLength(0);
|
||||
expect(valid.fields).toMatchObject({ isbn13: '9783426226223', isbn10: '0306406152' });
|
||||
|
||||
const tooLong = plainToInstance(BulkEditBookDockDto, { fields: { isbn13: '978-3426226223-9' }, enabledFields: ['isbn13'], mergeArrays: false });
|
||||
expect(tooLong.fields.isbn13).toBe('97834262262239');
|
||||
expect(await validate(tooLong)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { MetadataProviderKey, SERIES_INDEX_MAX_LENGTH, SERIES_INDEX_PATTERN, type SeriesIndex } from '@bookorbit/types';
|
||||
import { NormalizeIdentifier } from '../../../common/decorators/normalize-identifier.decorator';
|
||||
|
||||
@ValidatorConstraint({ name: 'hasCompleteDefaultDestination', async: false })
|
||||
class HasCompleteDefaultDestinationConstraint implements ValidatorConstraintInterface {
|
||||
@@ -86,8 +87,8 @@ export class BookDockMetadataFieldsDto {
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) publisher?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(100) language?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(10) isbn10?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(10) isbn10?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(500) seriesName?: string | null;
|
||||
@IsOptional() @IsString() coverUrl?: string;
|
||||
@IsOptional() @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) publishedDate?: string | null;
|
||||
@@ -110,20 +111,20 @@ export class BookDockMetadataFieldsDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BookDockCommunityRatingDto)
|
||||
communityRatings?: BookDockCommunityRatingDto[] | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(20) amazonId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(255) hardcoverId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) hardcoverEditionId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) openLibraryId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) itunesId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(20) audibleId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) librofmId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(255) koboId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(512) lubimyczytacId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(20) aladinId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(20) amazonId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(255) hardcoverId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) hardcoverEditionId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) openLibraryId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) itunesId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(20) audibleId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) librofmId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(255) koboId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(512) lubimyczytacId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(20) aladinId?: string | null;
|
||||
@IsOptional() @ValidateNested() @Type(() => BookDockComicMetadataDto) comicMetadata?: BookDockComicMetadataDto | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { MetadataProviderKey } from '@bookorbit/types';
|
||||
@@ -22,6 +23,7 @@ import { SaveProgressDto } from './save-progress.dto';
|
||||
import { SearchBooksDto } from './search-books.dto';
|
||||
import { UpsertAudioProgressDto } from './upsert-audio-progress.dto';
|
||||
import { UpdateBookMetadataDto } from './update-book-metadata.dto';
|
||||
import { UpdateBookMetadataAndLocksDto } from './update-book-metadata-and-locks.dto';
|
||||
import { UpdateBookAddedAtDto } from './update-book-added-at.dto';
|
||||
import { UpdatePersonalNoteDto } from './update-personal-note.dto';
|
||||
import { UpdateRatingDto } from './update-rating.dto';
|
||||
@@ -260,6 +262,54 @@ describe('Book DTO validation', () => {
|
||||
expect(Object.values(error?.constraints ?? {}).join(' ')).toBe('amazonId must be shorter than or equal to 20 characters');
|
||||
});
|
||||
|
||||
// Issue #1624: identifiers pasted from Amazon carry hyphens and invisible characters.
|
||||
it('cleans pasted identifiers before validating them instead of rejecting them', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
|
||||
|
||||
const result = (await pipe.transform(
|
||||
{
|
||||
metadata: {
|
||||
isbn13: '978-3426226223',
|
||||
isbn10: '\u200B0-8044-2957-x',
|
||||
amazonId: '\u200BB00X47ZVXM',
|
||||
audibleId: ' b002v0qk4c\u200E',
|
||||
googleBooksId: '\u00A0zyTC-Al_FPjgYC\uFEFF',
|
||||
hardcoverId: 'the-name-of-the-wind ',
|
||||
},
|
||||
lockedFields: [],
|
||||
},
|
||||
{ type: 'body', metatype: UpdateBookMetadataAndLocksDto, data: undefined },
|
||||
)) as UpdateBookMetadataAndLocksDto;
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
isbn13: '9783426226223',
|
||||
isbn10: '080442957X',
|
||||
amazonId: 'B00X47ZVXM',
|
||||
audibleId: 'B002V0QK4C',
|
||||
googleBooksId: 'zyTC-Al_FPjgYC',
|
||||
hardcoverId: 'the-name-of-the-wind',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an identifier that is still too long once cleaned, without truncating it', async () => {
|
||||
const dto = plainToInstance(UpdateBookMetadataDto, { isbn13: '978-3426226223-1', isbn10: '0-306-40615-22' });
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(dto.isbn13).toBe('97834262262231');
|
||||
expect(dto.isbn10).toBe('03064061522');
|
||||
expect(errors.map((error) => error.property).sort()).toEqual(['isbn10', 'isbn13']);
|
||||
});
|
||||
|
||||
it('leaves cleared and non-string identifiers to the existing validators', async () => {
|
||||
const cleared = plainToInstance(UpdateBookMetadataDto, { isbn13: null, amazonId: '', goodreadsId: ' \u200B ' });
|
||||
expect(await validate(cleared)).toHaveLength(0);
|
||||
expect(cleared).toMatchObject({ isbn13: null, amazonId: '', goodreadsId: '' });
|
||||
|
||||
const [error] = await errorsFor(UpdateBookMetadataDto, { isbn13: 9783426226223 });
|
||||
expect(error?.property).toBe('isbn13');
|
||||
expect(error?.constraints).toHaveProperty('isString');
|
||||
});
|
||||
|
||||
it('normalizes a zero page count to null and validates other page count values', async () => {
|
||||
// A provider can return a page count of 0 (issue #329); it must normalize to null instead of failing validation.
|
||||
const zeroed = plainToInstance(UpdateBookMetadataDto, { pageCount: 0 });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Transform, Type } from 'class-transformer';
|
||||
import { MetadataProviderKey, SERIES_INDEX_MAX_LENGTH, SERIES_INDEX_PATTERN, type SeriesIndex } from '@bookorbit/types';
|
||||
import { MAX_SERIES_TOTAL_BOOKS } from '../../../common/utils/series-total-books.utils';
|
||||
import { PROVIDER_ID_MAX_LENGTHS } from '../../../common/utils/provider-id.utils';
|
||||
import { NormalizeIdentifier } from '../../../common/decorators/normalize-identifier.decorator';
|
||||
import { CustomMetadataValueDto } from '../../custom-metadata/dto/custom-metadata-value.dto';
|
||||
|
||||
export class AudiobookChapterDto {
|
||||
@@ -59,28 +60,28 @@ export class UpdateBookMetadataDto {
|
||||
@IsOptional() @IsString() @MaxLength(500) seriesName?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(SERIES_INDEX_MAX_LENGTH) @Matches(SERIES_INDEX_PATTERN) seriesIndex?: SeriesIndex | null;
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => BookSeriesMembershipDto) seriesMemberships?: BookSeriesMembershipDto[] | null;
|
||||
@IsOptional() @IsString() @MaxLength(10) isbn10?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(10) isbn10?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null;
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CommunityRatingDto) communityRatings?: CommunityRatingDto[] | null;
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) authors?: string[];
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) genres?: string[];
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.googleBooksId) googleBooksId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.goodreadsId) goodreadsId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.amazonId) amazonId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.hardcoverId) hardcoverId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.hardcoverEditionId) hardcoverEditionId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.openLibraryId) openLibraryId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.itunesId) itunesId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.audibleId) audibleId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.librofmId) librofmId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.koboId) koboId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.googleBooksId) googleBooksId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.goodreadsId) goodreadsId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.amazonId) amazonId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.hardcoverId) hardcoverId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.hardcoverEditionId) hardcoverEditionId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.openLibraryId) openLibraryId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.itunesId) itunesId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.audibleId) audibleId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.librofmId) librofmId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.koboId) koboId?: string | null;
|
||||
@IsOptional() @ValidateNested() @Type(() => AudioMetadataDto) audioMetadata?: AudioMetadataDto;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.comicvineId) comicvineId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.ranobedbId) ranobedbId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.lubimyczytacId) lubimyczytacId?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.aladinId) aladinId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.comicvineId) comicvineId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.ranobedbId) ranobedbId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.lubimyczytacId) lubimyczytacId?: string | null;
|
||||
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.aladinId) aladinId?: string | null;
|
||||
@IsOptional() @ValidateNested() @Type(() => ComicMetadataDto) comicMetadata?: ComicMetadataDto;
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CustomMetadataValueDto) customMetadata?: CustomMetadataValueDto[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user