mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(metadata): add empty-field details and optional auto-search
Let users inspect missing fields without launching provider searches. Keep metadata searches manual by default and expose a remembered opt-in beside Search.
This commit is contained in:
@@ -27,6 +27,7 @@ import type {
|
|||||||
} from '@bookorbit/types'
|
} from '@bookorbit/types'
|
||||||
import { BOOK_FILE_WRITE_FIELD_LABELS, FORMAT_TO_GROUP, getPrimaryBookFile, isValidSeriesIndex, parseSeriesIndex } from '@bookorbit/types'
|
import { BOOK_FILE_WRITE_FIELD_LABELS, FORMAT_TO_GROUP, getPrimaryBookFile, isValidSeriesIndex, parseSeriesIndex } from '@bookorbit/types'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { metadataScoreColor } from '@/lib/metadata-score-color'
|
import { metadataScoreColor } from '@/lib/metadata-score-color'
|
||||||
import ChipInput from '@/components/ui/ChipInput.vue'
|
import ChipInput from '@/components/ui/ChipInput.vue'
|
||||||
@@ -301,7 +302,6 @@ const emptyFields = computed(() => {
|
|||||||
]
|
]
|
||||||
return checks.filter((check) => !check.filled).map((check) => check.label)
|
return checks.filter((check) => !check.filled).map((check) => check.label)
|
||||||
})
|
})
|
||||||
const emptyFieldsTitle = computed(() => t('book.detail.editMetadata.emptyFieldsTooltip', { fields: formatList(emptyFields.value) }))
|
|
||||||
const metadataScore = computed(() => props.book.metadataScore)
|
const metadataScore = computed(() => props.book.metadataScore)
|
||||||
const metadataScoreColour = computed(() => (metadataScore.value == null ? null : metadataScoreColor(metadataScore.value)))
|
const metadataScoreColour = computed(() => (metadataScore.value == null ? null : metadataScoreColor(metadataScore.value)))
|
||||||
|
|
||||||
@@ -871,17 +871,25 @@ function handleCoverChanged(medium: CoverMedium | null) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Popover v-if="emptyFields.length > 0">
|
||||||
v-if="emptyFields.length > 0"
|
<PopoverTrigger as-child>
|
||||||
type="button"
|
<button
|
||||||
class="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-dashed border-amber-500/40 bg-amber-500/10 px-2.5 text-xs font-semibold text-amber-600 transition-colors hover:bg-amber-500/15 sm:h-8 dark:text-amber-400"
|
type="button"
|
||||||
:title="emptyFieldsTitle"
|
class="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-dashed border-warning/40 bg-warning/10 px-2.5 text-xs font-semibold text-foreground transition-colors hover:bg-warning/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-8"
|
||||||
@click="handleOpenSearch"
|
:aria-label="t('book.detail.editMetadata.emptyFieldsCount', { count: emptyFields.length })"
|
||||||
>
|
>
|
||||||
<TriangleAlert class="size-3.5 shrink-0" aria-hidden="true" />
|
<TriangleAlert class="size-3.5 shrink-0 text-warning" aria-hidden="true" />
|
||||||
<span>{{ emptyFields.length }}</span>
|
<span>{{ emptyFields.length }}</span>
|
||||||
<span class="hidden @3xl/edit:inline">{{ t('book.detail.editMetadata.emptyFields', { count: emptyFields.length }) }}</span>
|
<span class="hidden @3xl/edit:inline">{{ t('book.detail.editMetadata.emptyFields', { count: emptyFields.length }) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" class="w-64 max-w-[calc(100vw-2rem)] p-3" :aria-label="t('book.detail.editMetadata.emptyFieldsHeading')">
|
||||||
|
<p class="mb-2 text-sm font-semibold">{{ t('book.detail.editMetadata.emptyFieldsHeading') }}</p>
|
||||||
|
<ul class="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<li v-for="field in emptyFields" :key="field">{{ field }}</li>
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
import { computed, inject, onBeforeUnmount, onMounted, ref, useId } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { X } from '@lucide/vue'
|
import { SlidersHorizontal, X } from '@lucide/vue'
|
||||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { getBookMediaProfile } from '@bookorbit/types'
|
import { getBookMediaProfile } from '@bookorbit/types'
|
||||||
import type { BookDetail, BookMetadataLockField, CoverMedium, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
|
import type { BookDetail, BookMetadataLockField, CoverMedium, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
|
||||||
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
|
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
|
||||||
|
import { useMetadataSearchPreferences } from '../../../composables/useMetadataSearchPreferences'
|
||||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||||
import type { MetadataDiffApply } from '../../../composables/useMetadataDiff'
|
import type { MetadataDiffApply } from '../../../composables/useMetadataDiff'
|
||||||
import type { SecondCoverInput } from '../../../composables/useSecondCoverRow'
|
import type { SecondCoverInput } from '../../../composables/useSecondCoverRow'
|
||||||
@@ -22,6 +24,8 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const optionsId = useId()
|
||||||
|
const { autoSearchOnOpen } = useMetadataSearchPreferences()
|
||||||
const { coverUrl } = useCoverVersions()
|
const { coverUrl } = useCoverVersions()
|
||||||
const libraryAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
const libraryAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||||
const isAudiobookSearch = computed(() => getBookMediaProfile(props.book.files).primaryMediaKind === 'audiobook')
|
const isAudiobookSearch = computed(() => getBookMediaProfile(props.book.files).primaryMediaKind === 'audiobook')
|
||||||
@@ -117,10 +121,17 @@ const subtitle = computed(() =>
|
|||||||
.join(' \u00b7 '),
|
.join(' \u00b7 '),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
active = false
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const shouldSearchOnOpen = autoSearchOnOpen.value
|
||||||
await loadProviders(props.book.id)
|
await loadProviders(props.book.id)
|
||||||
const defaults = searchDefaults.value
|
const defaults = searchDefaults.value
|
||||||
if (defaults.title || defaults.isbn) runMetadataSearch({ title: defaults.title ?? '', author: defaults.author ?? '', isbn: defaults.isbn ?? '' })
|
if (active && shouldSearchOnOpen && (defaults.title || defaults.isbn))
|
||||||
|
runMetadataSearch({ title: defaults.title ?? '', author: defaults.author ?? '', isbn: defaults.isbn ?? '' })
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleOpenChange(open: boolean) {
|
function handleOpenChange(open: boolean) {
|
||||||
@@ -221,7 +232,39 @@ function handleCancel() {
|
|||||||
@retry-provider="handleRetry"
|
@retry-provider="handleRetry"
|
||||||
@apply="handleApply"
|
@apply="handleApply"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
>
|
||||||
|
<template #search-options>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid size-8 shrink-0 place-items-center rounded-lg border border-input bg-background text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
:aria-label="t('book.detail.editMetadata.searchDrawer.searchOptions')"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
class="w-72 max-w-[calc(100vw-2rem)] p-3"
|
||||||
|
:aria-label="t('book.detail.editMetadata.searchDrawer.searchOptions')"
|
||||||
|
>
|
||||||
|
<label class="flex cursor-pointer items-start gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
v-model="autoSearchOnOpen"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
:aria-describedby="`${optionsId}-hint`"
|
||||||
|
/>
|
||||||
|
{{ t('book.detail.editMetadata.searchDrawer.autoSearchOnOpen') }}
|
||||||
|
</label>
|
||||||
|
<p :id="`${optionsId}-hint`" class="mt-2 text-xs text-muted-foreground">
|
||||||
|
{{ t('book.detail.editMetadata.searchDrawer.autoSearchOnOpenHint') }}
|
||||||
|
</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
</MetadataMatchWorkspace>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -153,6 +153,36 @@ describe('EditMetadataTab cover tiles', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens a popover listing only empty fields without opening metadata search', async () => {
|
||||||
|
const wrapper = mountTab(makeBook({ publisher: 'Ace', publishedYear: 1965 }))
|
||||||
|
document.body.append(wrapper.element)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const badge = wrapper.get('[aria-label="book.detail.editMetadata.emptyFieldsCount"]')
|
||||||
|
await badge.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const popover = document.querySelector('[data-slot="popover-content"]')!
|
||||||
|
expect(popover).not.toBeNull()
|
||||||
|
expect(Array.from(popover.querySelectorAll('li'), (item) => item.textContent)).toEqual([
|
||||||
|
'book.detail.editMetadata.languageLabel',
|
||||||
|
'book.detail.editMetadata.pageCountLabel',
|
||||||
|
'book.detail.editMetadata.isbn13Label',
|
||||||
|
'book.detail.editMetadata.isbn10Label',
|
||||||
|
'book.detail.editMetadata.genresLabel',
|
||||||
|
'book.detail.editMetadata.tagsLabel',
|
||||||
|
'book.detail.editMetadata.descriptionLabel',
|
||||||
|
])
|
||||||
|
expect(badge.attributes('aria-expanded')).toBe('true')
|
||||||
|
expect(wrapper.findComponent(MetadataSearchDrawerStub).exists()).toBe(false)
|
||||||
|
expect(vi.mocked(api).mock.calls.some(([url]) => String(url).includes('/metadata-fetch/stream'))).toBe(false)
|
||||||
|
|
||||||
|
await wrapper.setProps({ book: makeBook({ publisher: 'Ace', publishedYear: 1965, language: 'en' }) })
|
||||||
|
await flushPromises()
|
||||||
|
expect(popover.textContent).not.toContain('book.detail.editMetadata.languageLabel')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('enables Save when the only change is an unsaved cover', async () => {
|
it('enables Save when the only change is an unsaved cover', async () => {
|
||||||
const wrapper = mountTab(makeBook())
|
const wrapper = mountTab(makeBook())
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, ref } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { BookDetail, CoverMedium } from '@bookorbit/types'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import MetadataSearchDrawer from '../MetadataSearchDrawer.vue'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise<Response>>() }))
|
||||||
|
const authUser = ref({ id: 3 })
|
||||||
|
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: authUser }) }))
|
||||||
|
|
||||||
|
const WorkspaceStub = defineComponent({
|
||||||
|
props: ['searchDefaults', 'hasSearched'],
|
||||||
|
emits: ['search'],
|
||||||
|
template: '<div><slot name="search-options" /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const book = {
|
||||||
|
id: 7,
|
||||||
|
title: 'Dune',
|
||||||
|
authors: [{ name: 'Frank Herbert' }],
|
||||||
|
isbn13: '9780441172719',
|
||||||
|
files: [],
|
||||||
|
coverMedia: [],
|
||||||
|
covers: { ebook: null, audio: null },
|
||||||
|
} as unknown as BookDetail
|
||||||
|
|
||||||
|
function mountDrawer(coverMedia: CoverMedium[] = []) {
|
||||||
|
return mount(MetadataSearchDrawer, {
|
||||||
|
props: { book: { ...book, coverMedia }, lockedFields: [] },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Sheet: { template: '<div><slot /></div>' },
|
||||||
|
SheetContent: { template: '<div><slot /></div>' },
|
||||||
|
SheetTitle: { template: '<h2><slot /></h2>' },
|
||||||
|
SheetDescription: { template: '<p><slot /></p>' },
|
||||||
|
SheetClose: true,
|
||||||
|
MetadataMatchShortcuts: true,
|
||||||
|
MetadataMatchWorkspace: WorkspaceStub,
|
||||||
|
Popover: { template: '<div><slot /></div>' },
|
||||||
|
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||||
|
PopoverContent: { template: '<div><slot /></div>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchCalls() {
|
||||||
|
return vi.mocked(api).mock.calls.filter(([url]) => String(url).includes('/metadata-fetch/stream'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MetadataSearchDrawer automatic search', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
authUser.value = { id: 3 }
|
||||||
|
localStorage.clear()
|
||||||
|
vi.mocked(api).mockReset()
|
||||||
|
vi.mocked(api).mockImplementation(async (url) =>
|
||||||
|
String(url).includes('/metadata-fetch/providers')
|
||||||
|
? new Response(JSON.stringify([{ key: 'google', label: 'Google Books', identifiable: true }]))
|
||||||
|
: new Response(''),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefills the query without searching either medium until Search is requested', async () => {
|
||||||
|
const wrapper = mountDrawer(['ebook', 'audio'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(searchCalls()).toHaveLength(0)
|
||||||
|
expect(wrapper.get('input[type="checkbox"]').element).toHaveProperty('checked', false)
|
||||||
|
const workspace = wrapper.getComponent(WorkspaceStub)
|
||||||
|
expect(workspace.props('searchDefaults')).toEqual({ title: 'Dune', author: 'Frank Herbert', isbn: '9780441172719' })
|
||||||
|
workspace.vm.$emit('search', { title: 'Dune revised', author: 'Frank Herbert', isbn: '' })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(searchCalls()).toHaveLength(2)
|
||||||
|
expect(searchCalls()[0]![0]).toContain('title=Dune+revised')
|
||||||
|
expect(searchCalls()[1]![0]).toContain('mediaKind=audiobook')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remembers opting in on this browser and searches on the next opening', async () => {
|
||||||
|
const wrapper = mountDrawer()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchCalls()).toHaveLength(0)
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
const reopened = mountDrawer()
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchCalls()).toHaveLength(1)
|
||||||
|
expect(searchCalls()[0]![0]).toContain('isbn=9780441172719')
|
||||||
|
await reopened.get('input[type="checkbox"]').setValue(false)
|
||||||
|
await flushPromises()
|
||||||
|
reopened.unmount()
|
||||||
|
|
||||||
|
const manual = mountDrawer()
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchCalls()).toHaveLength(1)
|
||||||
|
manual.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps another account on manual search', async () => {
|
||||||
|
const wrapper = mountDrawer()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||||
|
await flushPromises()
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
authUser.value = { id: 4 }
|
||||||
|
const otherAccount = mountDrawer()
|
||||||
|
await flushPromises()
|
||||||
|
expect(otherAccount.get('input[type="checkbox"]').element).toHaveProperty('checked', false)
|
||||||
|
expect(searchCalls()).toHaveLength(0)
|
||||||
|
otherAccount.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start a delayed automatic search after the drawer closes', async () => {
|
||||||
|
localStorage.setItem('bookorbit:3:metadata-search-auto-start', 'true')
|
||||||
|
let resolveProviders!: (response: Response) => void
|
||||||
|
vi.mocked(api).mockReturnValueOnce(
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveProviders = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const wrapper = mountDrawer()
|
||||||
|
wrapper.unmount()
|
||||||
|
resolveProviders(new Response('[]'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchCalls()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -100,15 +100,18 @@ defineExpose({ focusTitle })
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<div class="flex items-center gap-1.5">
|
||||||
type="submit"
|
<button
|
||||||
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold whitespace-nowrap text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
type="submit"
|
||||||
:disabled="!canSearch"
|
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold whitespace-nowrap text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||||
>
|
:disabled="!canSearch"
|
||||||
<Loader2 v-if="busy" class="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
>
|
||||||
<Search v-else class="size-4" aria-hidden="true" />
|
<Loader2 v-if="busy" class="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||||
{{ t('common.search') }}
|
<Search v-else class="size-4" aria-hidden="true" />
|
||||||
</button>
|
{{ t('common.search') }}
|
||||||
|
</button>
|
||||||
|
<slot name="search-options" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="edited" class="flex items-center justify-between gap-2">
|
<div v-if="edited" class="flex items-center justify-between gap-2">
|
||||||
<span class="text-[11.5px] text-muted-foreground">{{ t('book.detail.editMetadata.match.query.edited') }}</span>
|
<span class="text-[11.5px] text-muted-foreground">{{ t('book.detail.editMetadata.match.query.edited') }}</span>
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ defineExpose({ focusQuery })
|
|||||||
<template>
|
<template>
|
||||||
<aside class="flex min-h-0 flex-col bg-card/50" :aria-label="t('book.detail.editMetadata.match.results.label')">
|
<aside class="flex min-h-0 flex-col bg-card/50" :aria-label="t('book.detail.editMetadata.match.results.label')">
|
||||||
<div class="grid gap-3 border-b border-border/60 px-3.5 pt-3.5 pb-3">
|
<div class="grid gap-3 border-b border-border/60 px-3.5 pt-3.5 pb-3">
|
||||||
<MetadataMatchQuery ref="query" :defaults="searchDefaults" :busy="isStreaming" @search="handleSearch" />
|
<MetadataMatchQuery ref="query" :defaults="searchDefaults" :busy="isStreaming" @search="handleSearch">
|
||||||
|
<template #search-options><slot name="search-options" /></template>
|
||||||
|
</MetadataMatchQuery>
|
||||||
<MetadataMatchSources
|
<MetadataMatchSources
|
||||||
v-if="providers.length"
|
v-if="providers.length"
|
||||||
:providers="providers"
|
:providers="providers"
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ const goodreads: MetadataCandidate = {
|
|||||||
}
|
}
|
||||||
const collection: MetadataCandidate = { provider: 'google', providerId: 'set', title: 'Artemis Fowl: Books 1-4', authors: ['Eoin Colfer'] }
|
const collection: MetadataCandidate = { provider: 'google', providerId: 'set', title: 'Artemis Fowl: Books 1-4', authors: ['Eoin Colfer'] }
|
||||||
|
|
||||||
function mountWorkspace(props: Partial<InstanceType<typeof MetadataMatchWorkspace>['$props']> = {}) {
|
function mountWorkspace(props: Partial<InstanceType<typeof MetadataMatchWorkspace>['$props']> = {}, slots: Record<string, string> = {}) {
|
||||||
return mount(MetadataMatchWorkspace, {
|
return mount(MetadataMatchWorkspace, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
|
slots,
|
||||||
props: {
|
props: {
|
||||||
current,
|
current,
|
||||||
providerIds: { amazon: 'B002KP6DXQ' },
|
providerIds: { amazon: 'B002KP6DXQ' },
|
||||||
@@ -87,6 +88,14 @@ async function choose(wrapper: VueWrapper, key: string, option: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('MetadataMatchWorkspace', () => {
|
describe('MetadataMatchWorkspace', () => {
|
||||||
|
it('places search options beside the submit button inside the search form', () => {
|
||||||
|
const wrapper = mountWorkspace({}, { 'search-options': '<button type="button" aria-label="Search options">Options</button>' })
|
||||||
|
const options = wrapper.get('form[role="search"] [aria-label="Search options"]')
|
||||||
|
expect(options.element.previousElementSibling).toHaveProperty('type', 'submit')
|
||||||
|
expect(wrapper.find('header [aria-label="Search options"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('groups results by how likely they are this book and opens the linked record', () => {
|
it('groups results by how likely they are this book and opens the linked record', () => {
|
||||||
const wrapper = mountWorkspace()
|
const wrapper = mountWorkspace()
|
||||||
|
|
||||||
|
|||||||
@@ -434,7 +434,9 @@ defineExpose({ focusQuery: () => rail.value?.focusQuery() })
|
|||||||
@select-field-rules="handleSelectFieldRules"
|
@select-field-rules="handleSelectFieldRules"
|
||||||
@retry-provider="handleRetry"
|
@retry-provider="handleRetry"
|
||||||
@select="select"
|
@select="select"
|
||||||
/>
|
>
|
||||||
|
<template #search-options><slot name="search-options" /></template>
|
||||||
|
</MetadataMatchRail>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-col @5xl/match:flex" :class="screen === 'matches' && !fixedCandidate ? 'hidden' : 'flex'">
|
<div class="min-h-0 flex-col @5xl/match:flex" :class="screen === 'matches' && !fixedCandidate ? 'hidden' : 'flex'">
|
||||||
<MetadataMatchCompare
|
<MetadataMatchCompare
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||||
|
|
||||||
|
export function useMetadataSearchPreferences() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const storageKey = computed(() => `bookorbit:${user.value?.id ?? 'anonymous'}:metadata-search-auto-start`)
|
||||||
|
const autoSearchOnOpen = useStorage(storageKey, false)
|
||||||
|
|
||||||
|
return { autoSearchOnOpen }
|
||||||
|
}
|
||||||
@@ -5730,6 +5730,9 @@
|
|||||||
},
|
},
|
||||||
"searchDrawer": {
|
"searchDrawer": {
|
||||||
"searchTitle": "Search Metadata",
|
"searchTitle": "Search Metadata",
|
||||||
|
"autoSearchOnOpen": "Search automatically when opened",
|
||||||
|
"autoSearchOnOpenHint": "Uses the book's details next time this panel opens. Remembered for your account in this browser.",
|
||||||
|
"searchOptions": "Search options",
|
||||||
"compareTitle": "Compare Metadata",
|
"compareTitle": "Compare Metadata",
|
||||||
"searchSubtitle": "Find the best provider match for this book.",
|
"searchSubtitle": "Find the best provider match for this book.",
|
||||||
"compareSubtitle": "Review differences and apply only what you want."
|
"compareSubtitle": "Review differences and apply only what you want."
|
||||||
@@ -5949,6 +5952,8 @@
|
|||||||
"score": "Score",
|
"score": "Score",
|
||||||
"scoreTooltip": "Metadata score {score} out of 100",
|
"scoreTooltip": "Metadata score {score} out of 100",
|
||||||
"emptyFields": "{count, plural, one {empty field} other {empty fields}}",
|
"emptyFields": "{count, plural, one {empty field} other {empty fields}}",
|
||||||
|
"emptyFieldsCount": "{count, plural, one {# empty field} other {# empty fields}}",
|
||||||
|
"emptyFieldsHeading": "Empty metadata fields",
|
||||||
"emptyFieldsTooltip": "Not filled in yet: {fields}. Search online to look them up.",
|
"emptyFieldsTooltip": "Not filled in yet: {fields}. Search online to look them up.",
|
||||||
"libraryRow": "Library",
|
"libraryRow": "Library",
|
||||||
"primaryRow": "Primary",
|
"primaryRow": "Primary",
|
||||||
|
|||||||
Reference in New Issue
Block a user