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:
neon
2026-10-21 05:47:03 +00:00
parent edc5e41105
commit fbd7a2e125
16 changed files with 853 additions and 44 deletions
@@ -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) => {