From 23d33008475a2aac369cb6691550ea05db9f2afd Mon Sep 17 00:00:00 2001 From: s3ntin3l8 <58235613+s3ntin3l8@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:02:33 +0000 Subject: [PATCH] fix: normalise collection group keys so formatting variants don't create duplicate cards Extracts normalizeCollectionText to textUtils.ts and imports it in both AudiobooksView and CollectionView (replacing the local copy there). AudiobooksView.groupedCollections now normalises the Map key for both authors and series, so variants differing only in case/spacing/punctuation (e.g. "R.R." vs "R. R.", "Wheel of Time" vs "wheel of time") merge into a single card while the first-seen raw name is preserved for display. This keeps grouping in sync with CollectionView.matchesCurrentCollection, which already normalises both author and series names. Fixes #672. Co-Authored-By: Claude Sonnet 4.6 --- fe/src/__tests__/AudiobooksView.spec.ts | 123 ++++++++++++++++++++++++ fe/src/utils/textUtils.ts | 15 +++ fe/src/views/library/AudiobooksView.vue | 17 ++-- fe/src/views/library/CollectionView.vue | 12 +-- 4 files changed, 148 insertions(+), 19 deletions(-) diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/__tests__/AudiobooksView.spec.ts index 718836de9..42fe511f6 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 a9a3eb7b9..be065af39 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 f7b7fa4ec..74f680f69 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 bbbdd64ee..de03b79e6 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()