diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/__tests__/AudiobooksView.spec.ts index 718836de..42fe511f 100644 --- a/fe/src/__tests__/AudiobooksView.spec.ts +++ b/fe/src/__tests__/AudiobooksView.spec.ts @@ -795,4 +795,127 @@ describe('AudiobooksView Grouping', () => { await wrapper.vm.$nextTick() expect(wrapper.find('.series-bottom-placard').exists()).toBe(true) }) + + it('merges author cards when names differ only in spacing or punctuation around initials', async () => { + if ( + typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' + ) { + ;(globalThis as unknown as Record).ResizeObserver = class { + observe() {} + disconnect() {} + } + } + if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as unknown as Record).WebSocket = function () { + /* noop */ + } + } + + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/audiobooks', name: 'audiobooks', component: AudiobooksView }, + ], + }) + await router.push('/audiobooks') + await router.isReady().catch(() => {}) + + const store = useLibraryStore() + // "George R.R. Martin" (no spaces) vs "George R. R. Martin" (spaced) — same person + store.audiobooks = [ + { id: 1, title: 'A Clash of Kings', authors: ['George R. R. Martin'], files: [] }, + { id: 2, title: 'A Dance with Dragons', authors: ['George R.R. Martin'], files: [] }, + { id: 3, title: 'A Feast for Crows', authors: ['George R. R. Martin'], files: [] }, + ] as unknown as import('@/types').Audiobook[] + + store.fetchLibrary = vi.fn(async () => undefined) + const wrapper = mount(AudiobooksView, { + global: { + plugins: [pinia, router], + stubs: [ + 'BulkEditModal', + 'EditAudiobookModal', + 'CustomFilterModal', + 'FiltersDropdown', + 'CustomSelect', + ], + }, + }) + await new Promise((r) => setTimeout(r, 0)) + + const vm = getVm(wrapper) + await vm.setGroupBy?.('authors') + await wrapper.vm.$nextTick() + + const groupedCollections = vm.groupedCollections ?? [] + // Both name variants must collapse into a single author card + expect(groupedCollections).toHaveLength(1) + expect(groupedCollections[0].count).toBe(3) + // The surviving card keeps the first-seen raw display name (not the normalized key) + expect(groupedCollections[0].name).toBe('George R. R. Martin') + }) + + it('merges series cards when series names differ only in case or spacing', async () => { + if ( + typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' + ) { + ;(globalThis as unknown as Record).ResizeObserver = class { + observe() {} + disconnect() {} + } + } + if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as unknown as Record).WebSocket = function () { + /* noop */ + } + } + + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/audiobooks', name: 'audiobooks', component: AudiobooksView }, + ], + }) + await router.push('/audiobooks') + await router.isReady().catch(() => {}) + + const store = useLibraryStore() + // "Wheel of Time" vs "wheel of time" — same series, formatting drift across books + store.audiobooks = [ + { id: 1, title: 'The Eye of the World', series: 'Wheel of Time', imageUrl: 'c1', files: [] }, + { id: 2, title: 'The Great Hunt', series: 'wheel of time', imageUrl: 'c2', files: [] }, + ] as unknown as import('@/types').Audiobook[] + + store.fetchLibrary = vi.fn(async () => undefined) + const wrapper = mount(AudiobooksView, { + global: { + plugins: [pinia, router], + stubs: [ + 'BulkEditModal', + 'EditAudiobookModal', + 'CustomFilterModal', + 'FiltersDropdown', + 'CustomSelect', + ], + }, + }) + await new Promise((r) => setTimeout(r, 0)) + + const vm = getVm(wrapper) + await vm.setGroupBy?.('series') + await wrapper.vm.$nextTick() + + const groupedCollections = vm.groupedCollections ?? [] + // Both spelling variants must collapse into a single series card + expect(groupedCollections).toHaveLength(1) + expect(groupedCollections[0].count).toBe(2) + // The surviving card keeps the first-seen raw display name (not the normalized key) + expect(groupedCollections[0].name).toBe('Wheel of Time') + }) }) diff --git a/fe/src/utils/textUtils.ts b/fe/src/utils/textUtils.ts index a9a3eb7b..be065af3 100644 --- a/fe/src/utils/textUtils.ts +++ b/fe/src/utils/textUtils.ts @@ -115,3 +115,18 @@ export function stripHtmlAndNormalize(text: string | undefined | null): string { return decodeHtmlEntities(raw) } + +/** + * Normalises a collection grouping key (author name, series name, etc.) for + * case- and punctuation-insensitive comparison and Map keying. + * NFKD → strip diacritics → lowercase → collapse non-alphanumeric runs to spaces → trim. + */ +export function normalizeCollectionText(value: string | undefined | null): string { + if (!value) return '' + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index f7b7fa4e..74f680f6 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -831,7 +831,7 @@ import type { Audiobook, AudiobookStatus, QualityProfile } from '@/types' import { evaluateRules } from '@/utils/customFilterEvaluator' import type { RuleLike } from '@/utils/customFilterEvaluator' import { computeAudiobookStatus, formatAudiobookStatus } from '@/utils/audiobookStatus' -import { safeText } from '@/utils/textUtils' +import { normalizeCollectionText, safeText } from '@/utils/textUtils' import { formatSeriesMemberships } from '@/utils/seriesUtils' import { getPlaceholderUrl } from '@/utils/placeholder' import { errorTracking } from '@/services/errorTracking' @@ -1388,14 +1388,15 @@ const groupedCollections = computed(() => { >() books.forEach((book) => { - const keys = + const rawKeys = groupBy.value === 'authors' ? book.authors?.[0] ? [book.authors[0]] : [] : getBookSeriesNames(book) - for (const key of keys) { - if (!key) continue + for (const rawKey of rawKeys) { + if (!rawKey) continue + const key = normalizeCollectionText(rawKey) if (!groups.has(key)) { if (groupBy.value === 'authors') { // Prefer override (fetched author image) first, then author ASIN, then book cover @@ -1407,10 +1408,10 @@ const groupedCollections = computed(() => { // Access via (global) variable — will be undefined initially. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - if (authorCoverOverrides && authorCoverOverrides[key]) { + if (authorCoverOverrides && authorCoverOverrides[rawKey]) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - cover = authorCoverOverrides[key] + cover = authorCoverOverrides[rawKey] } } catch {} @@ -1421,9 +1422,9 @@ const groupedCollections = computed(() => { } catch {} } - groups.set(key, { name: key, count: 0, coverUrl: cover }) + groups.set(key, { name: rawKey, count: 0, coverUrl: cover }) } else { - groups.set(key, { name: key, count: 0, coverUrls: [] }) + groups.set(key, { name: rawKey, count: 0, coverUrls: [] }) } } const group = groups.get(key)! diff --git a/fe/src/views/library/CollectionView.vue b/fe/src/views/library/CollectionView.vue index bbbdd64e..de03b79e 100644 --- a/fe/src/views/library/CollectionView.vue +++ b/fe/src/views/library/CollectionView.vue @@ -811,7 +811,7 @@ import type { SeriesLookupResponse, } from '@/types' import { computeAudiobookStatus, formatAudiobookStatus } from '@/utils/audiobookStatus' -import { safeText, stripHtmlAndNormalize } from '@/utils/textUtils' +import { normalizeCollectionText, safeText, stripHtmlAndNormalize } from '@/utils/textUtils' import { useProtectedImages } from '@/composables/useProtectedImages' import { getPreferredSearchLanguageFilter, @@ -931,16 +931,6 @@ const seriesMetadataContextLabel = computed(() => { return `${seriesRegionLabel.value} / ${seriesLanguageLabel.value}` }) -function normalizeCollectionText(value: string | undefined | null): string { - if (!value) return '' - return value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, ' ') - .trim() -} - function normalizeIdentifier(value: string | undefined | null): string { if (!value) return '' return value.replace(/[^A-Za-z0-9]/g, '').toUpperCase()