Files
bookorbit/client/src/features/book/composables/useMetadataEditor.ts
T
neon 2faa32c09d fix(metadata): bound provider ids and surface why a save was rejected
A provider identifier parsed out of an OPF was passed on verbatim, so a Calibre
`opf:scheme="AMAZON"` holding a product URL reached a varchar(20) column as a 36-character
value. It could not be stored, and the next manual save of that book was rejected for a field
the user never typed into. Drop an identifier that cannot fit its column rather than truncating
it: an overflowing value is not a long identifier, it is a different kind of value, and its
first 20 characters are not an ASIN either. Enforce the same bound where every extractor
converges, so no parser can fail a metadata write with a Postgres 22001.

Keep the server's description of a rejected save instead of discarding the response body. Only
that text names the field at fault, so the editor showed a bare "HTTP 400" and left the reason
in the network tab. It stays untranslated, so it is preferred over the catalog message rather
than replacing it, and the failure is carried as a structured value so the component still
translates everything else.

Tie the DTO limits to the column widths they were copied from, and guard the pair with a test,
since nothing connected them when they drifted apart.

Stand Node's built-in web storage down in the client test run. Node 26 defines localStorage on
globalThis, jsdom shares that object, and its getter reads as undefined without
--localstorage-file, which failed 229 tests across 28 unrelated suites on CI.

Closes #1015
2026-08-16 02:30:34 +00:00

341 lines
12 KiB
TypeScript

import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { readApiErrorDetail } from '@/lib/api-error'
import {
FORMAT_TO_GROUP,
type BookCommunityRating,
type BookDetail,
type BookMetadataLockField,
type BookMetadataSaveResult,
type CustomMetadataBookValue,
type CustomMetadataBookValueInput,
} from '@bookorbit/types'
export type EditableSeriesMembership = {
seriesName: string
seriesIndex: number | null
/** Series-level: saving it changes the total for every book in the series and every user. */
expectedBookCount: number | null
}
const ROOT_FIELDS = [
'title',
'subtitle',
'description',
'publisher',
'publishedDate',
'publishedYear',
'language',
'pageCount',
'isbn10',
'isbn13',
'rating',
'authors',
'genres',
'tags',
'googleBooksId',
'goodreadsId',
'amazonId',
'hardcoverId',
'hardcoverEditionId',
'openLibraryId',
'itunesId',
'audibleId',
'librofmId',
'koboId',
'comicvineId',
'ranobedbId',
'lubimyczytacId',
'aladinId',
] as const
const COMIC_FIELDS = {
issueNumber: 'comicIssueNumber',
volumeName: 'comicVolumeName',
storyArcs: 'comicStoryArcs',
pencillers: 'comicPencillers',
inkers: 'comicInkers',
colorists: 'comicColorists',
letterers: 'comicLetterers',
coverArtists: 'comicCoverArtists',
characters: 'comicCharacters',
teams: 'comicTeams',
locations: 'comicLocations',
} as const
const AUDIO_FIELDS = {
narrators: 'narrators',
durationSeconds: 'durationSeconds',
abridged: 'abridged',
} as const
function normalizePageCount(value: number | null): number | null {
return typeof value === 'number' && value > 0 ? value : null
}
export function normalizeSeriesMemberships(values: readonly EditableSeriesMembership[]): EditableSeriesMembership[] {
const seen = new Set<string>()
const out: EditableSeriesMembership[] = []
for (const value of values) {
const seriesName = value.seriesName.trim()
if (!seriesName) continue
const key = seriesName.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push({ seriesName, seriesIndex: value.seriesIndex ?? null, expectedBookCount: value.expectedBookCount ?? null })
}
return out
}
function seriesMembershipsFromBook(book: BookDetail): EditableSeriesMembership[] {
const memberships = book.seriesMemberships ?? []
if (memberships.length > 0) {
return normalizeSeriesMemberships(
memberships.map((membership) => ({
seriesName: membership.seriesName,
seriesIndex: membership.seriesIndex,
expectedBookCount: membership.expectedBookCount ?? null,
})),
)
}
return book.seriesName ? [{ seriesName: book.seriesName, seriesIndex: book.seriesIndex, expectedBookCount: null }] : []
}
function changedCustomMetadataPayload(
currentValues: readonly CustomMetadataBookValue[],
previousValues: readonly CustomMetadataBookValue[],
): CustomMetadataBookValueInput[] {
const previousByFieldId = new Map(previousValues.map((field) => [field.fieldId, field.value]))
return currentValues
.filter((field) => JSON.stringify(field.value) !== JSON.stringify(previousByFieldId.get(field.fieldId)))
.map((field) => ({ fieldId: field.fieldId, value: field.value }))
}
/**
* A failed save, kept structured rather than pre-rendered: `detail` is the server's own English
* description of what it rejected, which no catalog can translate, while everything else has to
* resolve to a translated message in the component.
*/
export type MetadataSaveFailure = {
/** Server-supplied description, or null when the response carried none. */
detail: string | null
/** HTTP status, or null when the request failed before producing a response. */
status: number | null
}
export function useMetadataEditor() {
const saving = ref(false)
const error = ref<MetadataSaveFailure | null>(null)
const form = reactive({
title: null as string | null,
subtitle: null as string | null,
description: null as string | null,
publisher: null as string | null,
publishedDate: null as string | null,
publishedYear: null as number | null,
language: null as string | null,
pageCount: null as number | null,
communityRatings: [] as BookCommunityRating[],
seriesName: null as string | null,
seriesIndex: null as number | null,
seriesMemberships: [] as EditableSeriesMembership[],
isbn10: null as string | null,
isbn13: null as string | null,
rating: null as number | null,
authors: [] as string[],
genres: [] as string[],
tags: [] as string[],
narrators: [] as string[],
durationSeconds: null as number | null,
abridged: false as boolean,
googleBooksId: null as string | null,
goodreadsId: null as string | null,
amazonId: null as string | null,
hardcoverId: null as string | null,
hardcoverEditionId: null as string | null,
openLibraryId: null as string | null,
itunesId: null as string | null,
audibleId: null as string | null,
librofmId: null as string | null,
koboId: null as string | null,
comicvineId: null as string | null,
ranobedbId: null as string | null,
lubimyczytacId: null as string | null,
aladinId: null as string | null,
comicIssueNumber: null as string | null,
comicVolumeName: null as string | null,
comicStoryArcs: [] as string[],
comicPencillers: [] as string[],
comicInkers: [] as string[],
comicColorists: [] as string[],
comicLetterers: [] as string[],
comicCoverArtists: [] as string[],
comicCharacters: [] as string[],
comicTeams: [] as string[],
comicLocations: [] as string[],
customMetadata: [] as CustomMetadataBookValue[],
})
const snapshot = ref(JSON.stringify(form))
const includeAudioMetadata = ref(false)
let loadedBookId: number | null = null
const isDirty = computed(() => JSON.stringify(form) !== snapshot.value)
function load(book: BookDetail) {
form.title = book.title
form.subtitle = book.subtitle
form.description = book.description
form.publisher = book.publisher
form.publishedDate = book.publishedDate
form.publishedYear = book.publishedYear
form.language = book.language
form.pageCount = book.pageCount
form.communityRatings = [...book.communityRatings]
form.seriesName = book.seriesName
form.seriesIndex = book.seriesIndex
form.seriesMemberships = seriesMembershipsFromBook(book)
form.isbn10 = book.isbn10
form.isbn13 = book.isbn13
form.rating = book.rating ?? null
form.authors = book.authors.map((a) => a.name)
form.genres = [...book.genres]
form.tags = [...book.tags]
form.narrators = book.audioMetadata?.narrators?.map((n) => n.name) ?? []
form.durationSeconds = book.audioMetadata?.durationSeconds ?? null
form.abridged = book.audioMetadata?.abridged ?? false
form.googleBooksId = book.providerIds.google ?? null
form.goodreadsId = book.providerIds.goodreads ?? null
form.amazonId = book.providerIds.amazon ?? null
form.hardcoverId = book.providerIds.hardcover ?? null
form.hardcoverEditionId = book.hardcoverEditionId
form.openLibraryId = book.providerIds.openLibrary ?? null
form.itunesId = book.providerIds.itunes ?? null
form.audibleId = book.providerIds.audible ?? null
form.librofmId = book.providerIds.librofm ?? null
form.koboId = book.providerIds.kobo ?? null
form.comicvineId = book.providerIds.comicvine ?? null
form.ranobedbId = book.providerIds.ranobedb ?? null
form.lubimyczytacId = book.providerIds.lubimyczytac ?? null
form.aladinId = book.providerIds.aladin ?? null
const cm = book.comicMetadata
form.comicIssueNumber = cm?.issueNumber ?? null
form.comicVolumeName = cm?.volumeName ?? null
form.comicStoryArcs = cm?.storyArcs ?? []
form.comicPencillers = cm?.pencillers ?? []
form.comicInkers = cm?.inkers ?? []
form.comicColorists = cm?.colorists ?? []
form.comicLetterers = cm?.letterers ?? []
form.comicCoverArtists = cm?.coverArtists ?? []
form.comicCharacters = cm?.characters ?? []
form.comicTeams = cm?.teams ?? []
form.comicLocations = cm?.locations ?? []
form.customMetadata = (book.customMetadata ?? []).map((field) => ({ ...field }))
includeAudioMetadata.value = book.audioMetadata != null || book.files.some((f) => f.format != null && FORMAT_TO_GROUP[f.format] === 'audio')
snapshot.value = JSON.stringify(form)
loadedBookId = book.id
error.value = null
}
function syncFromBook(book: BookDetail): boolean {
if (loadedBookId === book.id && (saving.value || isDirty.value)) return false
load(book)
return true
}
function reset() {
const s = JSON.parse(snapshot.value)
Object.assign(form, s)
error.value = null
}
function buildPayload() {
const previous = JSON.parse(snapshot.value) as typeof form
const payload: Record<string, unknown> = {}
for (const field of ROOT_FIELDS) {
const current = field === 'pageCount' ? normalizePageCount(form.pageCount) : form[field]
if (JSON.stringify(current) !== JSON.stringify(previous[field])) {
payload[field] = current
}
}
const currentSeriesMemberships = normalizeSeriesMemberships(form.seriesMemberships)
const previousSeriesMemberships = normalizeSeriesMemberships(previous.seriesMemberships)
if (JSON.stringify(currentSeriesMemberships) !== JSON.stringify(previousSeriesMemberships)) {
payload.seriesMemberships = currentSeriesMemberships
}
const currentCommunityRatings = form.communityRatings.map(({ provider, rating, ratingCount }) => ({ provider, rating, ratingCount }))
const previousCommunityRatings = previous.communityRatings.map(({ provider, rating, ratingCount }) => ({ provider, rating, ratingCount }))
if (JSON.stringify(currentCommunityRatings) !== JSON.stringify(previousCommunityRatings)) {
payload.communityRatings = currentCommunityRatings
}
const comicMetadata: Record<string, unknown> = {}
for (const [payloadKey, formKey] of Object.entries(COMIC_FIELDS)) {
if (JSON.stringify(form[formKey]) !== JSON.stringify(previous[formKey])) {
comicMetadata[payloadKey] = form[formKey]
}
}
if (Object.keys(comicMetadata).length > 0) {
payload.comicMetadata = comicMetadata
}
if (includeAudioMetadata.value) {
const audioMetadata: Record<string, unknown> = {}
for (const [payloadKey, formKey] of Object.entries(AUDIO_FIELDS)) {
if (JSON.stringify(form[formKey]) !== JSON.stringify(previous[formKey])) {
audioMetadata[payloadKey] = form[formKey]
}
}
if (Object.keys(audioMetadata).length > 0) {
payload.audioMetadata = audioMetadata
}
}
const customMetadata = changedCustomMetadataPayload(form.customMetadata, previous.customMetadata)
if (customMetadata.length > 0) {
payload.customMetadata = customMetadata
}
return payload
}
function normalizeSaveResult(data: BookDetail | BookMetadataSaveResult): BookMetadataSaveResult {
if ('book' in data && 'libraryAutoWriteEnabled' in data) {
return data
}
return { book: data, write: null, libraryAutoWriteEnabled: false }
}
async function save(bookId: number, lockedFields: readonly BookMetadataLockField[]): Promise<BookMetadataSaveResult | null> {
saving.value = true
error.value = null
try {
const metadata = buildPayload()
const submittedSnapshot = JSON.stringify(form)
const shouldSyncFileWrite = Object.keys(metadata).length > 0
const res = await api(`/api/v1/books/${bookId}/metadata-and-locks${shouldSyncFileWrite ? '?syncFileWrite=true' : ''}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata, lockedFields }),
})
if (!res.ok) {
error.value = { detail: await readApiErrorDetail(res), status: res.status }
return null
}
const updated = normalizeSaveResult((await res.json()) as BookDetail | BookMetadataSaveResult)
snapshot.value = submittedSnapshot
return updated
} catch {
error.value = { detail: null, status: null }
return null
} finally {
saving.value = false
}
}
return { form, saving, error, isDirty, load, syncFromBook, reset, save }
}