Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions fe/src/__tests__/AudiobooksView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).ResizeObserver = class {
observe() {}
disconnect() {}
}
}
if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') {
;(globalThis as unknown as Record<string, unknown>).WebSocket = function () {
/* noop */
}
}

const pinia = createPinia()
setActivePinia(pinia)
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ 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<string, unknown>).ResizeObserver = class {
observe() {}
disconnect() {}
}
}
if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') {
;(globalThis as unknown as Record<string, unknown>).WebSocket = function () {
/* noop */
}
}

const pinia = createPinia()
setActivePinia(pinia)
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ 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')
})
})
15 changes: 15 additions & 0 deletions fe/src/utils/textUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
17 changes: 9 additions & 8 deletions fe/src/views/library/AudiobooksView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 {}

Expand All @@ -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)!
Expand Down
12 changes: 1 addition & 11 deletions fe/src/views/library/CollectionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down