mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): honor configured Amazon domain in book links
Use the sanitized provider-link setting so book detail URLs target the selected marketplace. Fixes #1423
This commit is contained in:
@@ -66,6 +66,7 @@ import StorygraphBookSyncGridItem from '@/features/storygraph/components/Storygr
|
||||
import BookEditionsCard from '@/features/book/components/detail/details/BookEditionsCard.vue'
|
||||
import BookReadingActivityCard from '@/features/book/components/detail/details/BookReadingActivityCard.vue'
|
||||
import { useBookReadingLog } from '@/features/book/composables/useBookReadingLog'
|
||||
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings'
|
||||
|
||||
type FileProgress = {
|
||||
percentage: number
|
||||
@@ -113,6 +114,7 @@ function togglePersonalReview() {
|
||||
}
|
||||
|
||||
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
|
||||
const { settings: providerLinkSettings, loadSettings: loadProviderLinkSettings } = useProviderLinkSettings()
|
||||
const {
|
||||
bookProgress: koreaderBookProgress,
|
||||
fetchBookProgress: fetchKoreaderProgress,
|
||||
@@ -122,6 +124,7 @@ const {
|
||||
onMounted(() => {
|
||||
void fetchWeights()
|
||||
void reloadReadingLog()
|
||||
void loadProviderLinkSettings()
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -715,7 +718,7 @@ const supplementalLoading = ref(false)
|
||||
const resettingFileIds = ref<number[]>([])
|
||||
const providerIconErrors = ref<Record<string, boolean>>({})
|
||||
|
||||
const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds))
|
||||
const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds, providerLinkSettings.value))
|
||||
|
||||
const communityRatingBadges = computed(() => {
|
||||
const linkByKey = new Map(providerLinks.value.map((link) => [link.key, link]))
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { BookDetail } from '@bookorbit/types'
|
||||
import DetailsTab from '../DetailsTab.vue'
|
||||
import BookReadingActivityCard from '../../details/BookReadingActivityCard.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
|
||||
@@ -154,9 +155,11 @@ describe('DetailsTab cover surface', () => {
|
||||
mocks.hasPermission.mockReset()
|
||||
mocks.hasPermission.mockReturnValue(true)
|
||||
mocks.user.value.settings.timezone = 'UTC'
|
||||
useProviderLinkSettings().settings.value = { amazonDomain: 'amazon.com' }
|
||||
|
||||
mocks.api.mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/metadata-preferences/provider-links')) return response({ amazonDomain: 'amazon.com' })
|
||||
if (url.includes('/metadata-score/weights')) return response({})
|
||||
if (url.includes('/playback-state')) return response(null)
|
||||
if (url.includes('/collections/membership')) return response([])
|
||||
@@ -541,6 +544,19 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(tooltips.some((t) => t.includes('4.3 / 5') && t.includes('12,345'))).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the configured Amazon domain for the book provider link', async () => {
|
||||
const defaultImplementation = mocks.api.getMockImplementation()!
|
||||
mocks.api.mockImplementation(async (input, init) => {
|
||||
if (String(input).includes('/metadata-preferences/provider-links')) return response({ amazonDomain: 'amazon.de' })
|
||||
return defaultImplementation(input, init)
|
||||
})
|
||||
|
||||
const wrapper = mountDetails(makeBook({ providerIds: { amazon: 'B012345678' } }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('a[href="https://www.amazon.de/dp/B012345678"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('places the sync grid items with the current book id', async () => {
|
||||
const wrapper = mountDetails(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
|
||||
}))
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { useProviderLinkSettings } from '../useProviderLinkSettings'
|
||||
|
||||
const apiMock = vi.mocked(api)
|
||||
|
||||
describe('useProviderLinkSettings', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset()
|
||||
useProviderLinkSettings().settings.value = { amazonDomain: 'amazon.com' }
|
||||
})
|
||||
|
||||
it('loads the sanitized provider-link settings', async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ amazonDomain: 'amazon.de' }),
|
||||
} as Response)
|
||||
|
||||
const { settings, loadSettings } = useProviderLinkSettings()
|
||||
await loadSettings()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/metadata-preferences/provider-links')
|
||||
expect(settings.value).toEqual({ amazonDomain: 'amazon.de' })
|
||||
})
|
||||
|
||||
it('keeps the default when the settings request fails', async () => {
|
||||
apiMock.mockResolvedValue({ ok: false } as Response)
|
||||
|
||||
const { settings, loadSettings } = useProviderLinkSettings()
|
||||
await loadSettings()
|
||||
|
||||
expect(settings.value).toEqual({ amazonDomain: 'amazon.com' })
|
||||
})
|
||||
|
||||
it('deduplicates concurrent settings requests', async () => {
|
||||
let resolveRequest!: (response: Response) => void
|
||||
apiMock.mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { loadSettings } = useProviderLinkSettings()
|
||||
const first = loadSettings()
|
||||
const second = loadSettings()
|
||||
resolveRequest({ ok: false } as Response)
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(apiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { ProviderLinkSettings } from '@/features/book/lib/provider-links'
|
||||
|
||||
const DEFAULT_SETTINGS: ProviderLinkSettings = { amazonDomain: 'amazon.com' }
|
||||
const settings = ref<ProviderLinkSettings>(DEFAULT_SETTINGS)
|
||||
let pendingRequest: Promise<void> | null = null
|
||||
|
||||
export function useProviderLinkSettings() {
|
||||
function loadSettings(): Promise<void> {
|
||||
if (pendingRequest) return pendingRequest
|
||||
|
||||
pendingRequest = (async () => {
|
||||
try {
|
||||
const response = await api('/api/v1/metadata-preferences/provider-links')
|
||||
if (!response.ok) return
|
||||
const data: unknown = await response.json()
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'amazonDomain' in data &&
|
||||
typeof data.amazonDomain === 'string' &&
|
||||
data.amazonDomain.trim()
|
||||
) {
|
||||
settings.value = { amazonDomain: data.amazonDomain }
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
pendingRequest = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pendingRequest
|
||||
}
|
||||
|
||||
return { settings, loadSettings }
|
||||
}
|
||||
@@ -139,6 +139,23 @@ describe('book provider links', () => {
|
||||
expect(comicVineIssueUrl(' 1126983 ')).toBe('https://comicvine.gamespot.com/issue/4000-1126983/')
|
||||
})
|
||||
|
||||
it('uses the configured Amazon domain for Amazon links', () => {
|
||||
const links = createBookProviderLinks({ amazon: 'B012345678' }, { amazonDomain: 'amazon.de' })
|
||||
|
||||
expect(links).toEqual([
|
||||
expect.objectContaining({
|
||||
key: MetadataProviderKey.AMAZON,
|
||||
url: 'https://www.amazon.de/dp/B012345678',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to Amazon US for an unsupported domain', () => {
|
||||
const links = createBookProviderLinks({ amazon: 'B012345678' }, { amazonDomain: 'example.invalid/path' })
|
||||
|
||||
expect(links[0]?.url).toBe('https://www.amazon.com/dp/B012345678')
|
||||
})
|
||||
|
||||
it('builds a trimmed and encoded Libro.fm audiobook URL', () => {
|
||||
expect(libroFmAudiobookUrl(' 978 123 ')).toBe('https://libro.fm/audiobooks/978%20123')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MetadataProviderKey, type ProviderIds } from '@bookorbit/types'
|
||||
import { normalizeAmazonDomain } from '@/lib/amazon-domain'
|
||||
import { providerIconPathSafe } from './provider-icons'
|
||||
|
||||
export type BookProviderLink = {
|
||||
@@ -10,9 +11,13 @@ export type BookProviderLink = {
|
||||
}
|
||||
|
||||
type ProviderLinkDefinition = Omit<BookProviderLink, 'url' | 'iconUrl'> & {
|
||||
url: (id: string) => string
|
||||
url: (id: string, settings: ProviderLinkSettings) => string
|
||||
}
|
||||
|
||||
export type ProviderLinkSettings = { amazonDomain: string }
|
||||
|
||||
const DEFAULT_LINK_SETTINGS: ProviderLinkSettings = { amazonDomain: 'amazon.com' }
|
||||
|
||||
function encoded(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
@@ -55,7 +60,7 @@ const PROVIDER_LINK_DEFINITIONS: readonly ProviderLinkDefinition[] = [
|
||||
{
|
||||
key: MetadataProviderKey.AMAZON,
|
||||
label: 'Amazon',
|
||||
url: (id) => `https://www.amazon.com/dp/${encoded(id)}`,
|
||||
url: (id, settings) => `https://www.${normalizeAmazonDomain(settings.amazonDomain)}/dp/${encoded(id)}`,
|
||||
fallback: 'A',
|
||||
},
|
||||
{
|
||||
@@ -120,7 +125,7 @@ const PROVIDER_LINK_DEFINITIONS: readonly ProviderLinkDefinition[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export function createBookProviderLinks(providerIds: ProviderIds): BookProviderLink[] {
|
||||
export function createBookProviderLinks(providerIds: ProviderIds, settings: ProviderLinkSettings = DEFAULT_LINK_SETTINGS): BookProviderLink[] {
|
||||
return PROVIDER_LINK_DEFINITIONS.flatMap((definition) => {
|
||||
const id = providerIds[definition.key]?.trim()
|
||||
if (!id) return []
|
||||
@@ -129,7 +134,7 @@ export function createBookProviderLinks(providerIds: ProviderIds): BookProviderL
|
||||
{
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
url: definition.url(id),
|
||||
url: definition.url(id, settings),
|
||||
iconUrl: providerIconPathSafe(definition.key),
|
||||
fallback: definition.fallback,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProviderConfigurations } from '@bookorbit/types'
|
||||
export { AMAZON_DOMAINS } from '@/lib/amazon-domain'
|
||||
|
||||
/**
|
||||
* The medium a source supplies metadata for. Sources are picked by medium ("I want
|
||||
@@ -12,7 +13,7 @@ export interface ProviderFieldDef {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'password' | 'select'
|
||||
options?: string[]
|
||||
options?: readonly string[]
|
||||
placeholder?: string
|
||||
helper?: string
|
||||
/** Editable while the provider is still off, because enabling it depends on this value. */
|
||||
@@ -39,28 +40,6 @@ export interface ProviderRowDef {
|
||||
/** A provider slice of the draft, addressed by field key rather than by concrete shape. */
|
||||
export type ProviderDraftEntry = { enabled: boolean } & Record<string, unknown>
|
||||
|
||||
export const AMAZON_DOMAINS = [
|
||||
'amazon.com',
|
||||
'amazon.co.uk',
|
||||
'amazon.de',
|
||||
'amazon.fr',
|
||||
'amazon.it',
|
||||
'amazon.es',
|
||||
'amazon.ca',
|
||||
'amazon.com.au',
|
||||
'amazon.co.jp',
|
||||
'amazon.in',
|
||||
'amazon.com.br',
|
||||
'amazon.com.mx',
|
||||
'amazon.nl',
|
||||
'amazon.se',
|
||||
'amazon.pl',
|
||||
'amazon.sg',
|
||||
'amazon.ae',
|
||||
'amazon.sa',
|
||||
'amazon.tr',
|
||||
]
|
||||
|
||||
export const AUDIBLE_DOMAINS = [
|
||||
'audible.com',
|
||||
'audible.co.uk',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const AMAZON_DOMAINS = [
|
||||
'amazon.com',
|
||||
'amazon.co.uk',
|
||||
'amazon.de',
|
||||
'amazon.fr',
|
||||
'amazon.it',
|
||||
'amazon.es',
|
||||
'amazon.ca',
|
||||
'amazon.com.au',
|
||||
'amazon.co.jp',
|
||||
'amazon.in',
|
||||
'amazon.com.br',
|
||||
'amazon.com.mx',
|
||||
'amazon.nl',
|
||||
'amazon.se',
|
||||
'amazon.pl',
|
||||
'amazon.sg',
|
||||
'amazon.ae',
|
||||
'amazon.sa',
|
||||
'amazon.tr',
|
||||
] as const
|
||||
|
||||
export function normalizeAmazonDomain(value: string): string {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return AMAZON_DOMAINS.some((domain) => domain === normalized) ? normalized : 'amazon.com'
|
||||
}
|
||||
Reference in New Issue
Block a user