diff --git a/README.md b/README.md index 0827d9766..bd54740d8 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Take your library anywhere with the official native BookOrbit app. Read ebooks, ### Library Management - **Multiple Libraries**: Isolate content with per-library folders, custom scan rules, and format priorities. -- **14 Metadata Providers**: Google Books, Open Library, Amazon, Goodreads, Kobo, Hardcover, Audible, Audnexus, Libro.fm, and iTunes, plus ComicVine for comics, RanobeDB for light novels, and Aladin and Lubimyczytać for Korean and Polish catalogs. Cover art is sourced separately from iTunes, DuckDuckGo, and AudiobookCovers. +- **12 Metadata Providers**: Google Books, Open Library, Amazon, Goodreads, Kobo, Hardcover, Audible, Audnexus, Libro.fm, and iTunes, plus ComicVine for comics and RanobeDB for light novels. [External providers](docs/METADATA_PROVIDERS.md) add more, such as Douban, FantLab, J-Novel Club, Libris, LubimyCzytac and MangaBaka. Cover art is sourced separately from iTunes, DuckDuckGo, and AudiobookCovers. - **Smart Scopes & Collections**: Organize your collection with curated lists and dynamic, rule-based saved filters. ### Platform & Delivery @@ -167,12 +167,18 @@ The download is pre-configured with your server URL and credentials, so there is ## Documentation and Contributing -Full documentation is at **[bookorbit.app](https://bookorbit.app/what-is-bookorbit)**, covering libraries, metadata, readers, Kobo sync, OPDS, users and permissions, OIDC setup, and more. +Full documentation lives at **[bookorbit.app](https://bookorbit.app/what-is-bookorbit)**: libraries, metadata, readers, Kobo sync, OPDS, users and permissions, OIDC and more. -For setting up book requests, see the [book requests guide](docs/BOOK_REQUESTS.md): indexers, -download clients, path mappings, automation, and the encryption key they all need. -For local development, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To contribute, see [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for the full workflow: branch naming, test expectations, PR checklist, and commit format. -For optional STAlign read-aloud generation and QuartzNet hardware acceleration, see [docs/READ_ALOUD.md](docs/READ_ALOUD.md). +**Guides** + +- **Book requests**: indexers, download clients, path mappings, automation and the encryption key they need. See the [book requests guide](https://bookorbit.app/book-requests/). +- **Read-aloud**: optional STAlign generation and QuartzNet hardware acceleration. See [docs/READ_ALOUD.md](docs/READ_ALOUD.md). +- **External metadata providers**: add a source that runs beside BookOrbit, such as FantLab or MangaBaka, with [docs/METADATA_PROVIDERS.md](docs/METADATA_PROVIDERS.md), or build your own with [docs/BUILDING_METADATA_PROVIDERS.md](docs/BUILDING_METADATA_PROVIDERS.md). + +**Contributing** + +- **Local development**: architecture, first-time setup, running, testing and conventions in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). +- **Contributing**: branch naming, test expectations, the PR checklist and commit format in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md). ## Unsupported Storage Configurations diff --git a/client/src/features/audit/audit-resources.ts b/client/src/features/audit/audit-resources.ts index b016134aa..12b12af5e 100644 --- a/client/src/features/audit/audit-resources.ts +++ b/client/src/features/audit/audit-resources.ts @@ -26,6 +26,7 @@ const domainByResource: Record = { [AuditResource.BookRequest]: 'content', [AuditResource.DownloadClient]: 'integrations', [AuditResource.RequestIndexer]: 'integrations', + [AuditResource.MetadataHttpProvider]: 'integrations', [AuditResource.Author]: 'people', [AuditResource.AppSettings]: 'settings', [AuditResource.Genre]: 'metadata', diff --git a/client/src/features/book-requests/components/RequestSearchPanel.vue b/client/src/features/book-requests/components/RequestSearchPanel.vue index 8a5075dfc..82aa70f8d 100644 --- a/client/src/features/book-requests/components/RequestSearchPanel.vue +++ b/client/src/features/book-requests/components/RequestSearchPanel.vue @@ -422,8 +422,8 @@ function providerFallback(provider: MetadataProviderKey): string { function providerSourceStyle(provider: MetadataProviderKey): Record { const color = getProviderColor(provider) return { - borderColor: `${color}66`, - backgroundColor: `${color}12`, + borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, + backgroundColor: `color-mix(in srgb, ${color} 7%, transparent)`, } } diff --git a/client/src/features/book/components/BookFilterBuilder.vue b/client/src/features/book/components/BookFilterBuilder.vue index 8d22c909c..a53bc8b30 100644 --- a/client/src/features/book/components/BookFilterBuilder.vue +++ b/client/src/features/book/components/BookFilterBuilder.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { Plus, Trash2, X } from '@lucide/vue' import { COMMUNITY_RATING_PROVIDER_KEYS, + isBuiltInMetadataProviderKey, CUSTOM_FIELD_OPERATORS, CUSTOM_FIELD_TYPE_OPERATORS, FIELD_OPERATORS, @@ -24,7 +25,8 @@ import { READ_STATUSES } from '@bookorbit/types' import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields' import { fieldLabel, operatorLabel } from '@/features/book/lib/filter-labels' import { providerIconPathSafe } from '@/features/book/lib/provider-icons' -import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' +import { providerShortLabel } from '@/lib/provider-colors' +import { useMetadataProviderLabels } from '@/features/book/composables/useMetadataProviderLabels' import { useLibraries } from '@/features/library/composables/useLibraries' import FilterChipTypeahead from './FilterChipTypeahead.vue' import FilterFormatPicker from './FilterFormatPicker.vue' @@ -97,13 +99,18 @@ const READ_STATUS_LABELS = computed>(() => ({ abandoned: t('book.readStatus.abandoned'), })) -const COMMUNITY_RATING_PROVIDER_OPTIONS = computed<{ value: CommunityRatingProvider; label: string }[]>(() => [ - { value: 'any', label: t('book.filter.anyProvider') }, - ...COMMUNITY_RATING_PROVIDER_KEYS.map((provider) => ({ - value: provider, - label: PROVIDER_SHORT_LABELS[provider] ?? provider, - })), -]) +const { labels: providerLabels } = useMetadataProviderLabels() +// External providers' ratings can be filtered on too; they join the built-in list once known. +const COMMUNITY_RATING_PROVIDER_OPTIONS = computed<{ value: CommunityRatingProvider; label: string }[]>(() => { + const external = Object.keys(providerLabels.value).filter((key) => !isBuiltInMetadataProviderKey(key)) + return [ + { value: 'any', label: t('book.filter.anyProvider') }, + ...[...COMMUNITY_RATING_PROVIDER_KEYS, ...external].map((provider) => ({ + value: provider, + label: providerShortLabel(provider, providerLabels.value[provider]), + })), + ] +}) const ENDPOINT_BY_FIELD: Partial> = { author: '/api/v1/metadata/authors', diff --git a/client/src/features/book/components/BookQuickView.vue b/client/src/features/book/components/BookQuickView.vue index 01fc7ef90..d34dba42e 100644 --- a/client/src/features/book/components/BookQuickView.vue +++ b/client/src/features/book/components/BookQuickView.vue @@ -106,8 +106,8 @@ const publishedDisplay = computed(() => (detail.value ? displayPublishedDate(det function providerLinkStyle(provider: string) { const color = getProviderColor(provider) return { - borderColor: `${color}66`, - backgroundColor: `${color}12`, + borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, + backgroundColor: `color-mix(in srgb, ${color} 7%, transparent)`, } } diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index 2741916db..fff86d596 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -26,10 +26,11 @@ import { import BookFormatChip from '@/features/book/components/BookFormatChip.vue' import { bookFormatEntries, fileFormatKey, formatKeyName } from '@/features/book/lib/book-formats' import { providerIconPathSafe } from '@/features/book/lib/provider-icons' -import { createBookProviderLinks } from '@/features/book/lib/provider-links' +import { createBookProviderLinks, createExternalProviderLinks } from '@/features/book/lib/provider-links' +import { useMetadataProviderLabels } from '@/features/book/composables/useMetadataProviderLabels' import { faceMedium } from '@/features/book/lib/cover-slots' import { readingDateToDateKey } from '@/features/book/lib/reading-date' -import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' +import { getProviderColor, providerShortLabel } from '@/lib/provider-colors' import { useCoverVersions } from '@/features/book/composables/useCoverVersions' import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio' import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS, isComicFormat } from '@bookorbit/types' @@ -498,12 +499,13 @@ function formatDuration(seconds: number | null | undefined): string { const localRating = ref(null) const hoverRating = ref(null) const displayRating = computed(() => hoverRating.value ?? localRating.value) +const { labels: providerLabels } = useMetadataProviderLabels() const communityRatingByProvider = computed(() => { const map: Record = {} for (const rating of props.book.communityRatings) { if (rating.rating == null || !Number.isFinite(rating.rating)) continue const score = Number.isInteger(rating.rating) ? rating.rating.toFixed(0) : rating.rating.toFixed(1) - const label = PROVIDER_SHORT_LABELS[rating.provider] ?? rating.provider + const label = providerShortLabel(rating.provider, providerLabels.value[rating.provider]) const value = formatCommunityRatingValue(rating.rating, rating.ratingCount) map[rating.provider] = { score, tooltip: `${label} ${value}` } } @@ -825,7 +827,10 @@ const supplementalLoading = ref(false) const resettingFileIds = ref([]) const providerIconErrors = ref>({}) -const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds, providerLinkSettings.value)) +const providerLinks = computed(() => [ + ...createBookProviderLinks(props.book.providerIds, providerLinkSettings.value), + ...createExternalProviderLinks(props.book.providerIds, props.book.providerLinks, providerLabels.value), +]) const communityRatingBadges = computed(() => { const linkByKey = new Map(providerLinks.value.map((link) => [link.key, link])) @@ -834,7 +839,7 @@ const communityRatingBadges = computed(() => { .map((rating) => { const link = linkByKey.get(rating.provider) const meta = communityRatingByProvider.value[rating.provider] - const label = PROVIDER_SHORT_LABELS[rating.provider] ?? rating.provider + const label = providerShortLabel(rating.provider, providerLabels.value[rating.provider]) return { key: rating.provider, label, @@ -845,7 +850,6 @@ const communityRatingBadges = computed(() => { tooltip: meta?.tooltip ?? '', } }) - .filter((badge) => badge.iconUrl) }) const unlinkedCommunityBadges = computed(() => communityRatingBadges.value.filter((badge) => !badge.url)) @@ -1002,8 +1006,8 @@ function formatDate(iso: string): string { function providerLinkStyle(provider: string) { const color = getProviderColor(provider) return { - borderColor: `${color}66`, - backgroundColor: `${color}12`, + borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, + backgroundColor: `color-mix(in srgb, ${color} 7%, transparent)`, } } diff --git a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue index bf9822ac5..963bbd5f5 100644 --- a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue +++ b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue @@ -29,6 +29,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { api } from '@/lib/api' +import { registerProviderAppearance } from '@/lib/provider-appearance' import { metadataScoreColor } from '@/lib/metadata-score-color' import ChipInput from '@/components/ui/ChipInput.vue' import IdentifierInput from '@/components/ui/IdentifierInput.vue' @@ -193,6 +194,20 @@ const coverPanel = ref | null>(null) const searchOpen = ref(false) const availableMetadataProviders = ref(null) const visibleProviderIdFields = computed(() => filterProviderIdFields(availableMetadataProviders.value)) +/** + * External providers' ids: one row per registered external provider, plus any id the book still + * holds from a provider that has since been removed, so it can be read and cleared. + */ +const externalProviderIdRows = computed(() => { + const rows = new Map() + for (const provider of availableMetadataProviders.value ?? []) { + if (provider.source === 'external') rows.set(provider.key, { key: provider.key, label: provider.label, registered: true }) + } + for (const key of Object.keys(form.externalProviderIds)) { + if (!rows.has(key)) rows.set(key, { key, label: key, registered: false }) + } + return [...rows.values()] +}) let providerLoadToken = 0 async function loadAvailableMetadataProviders(bookId: number) { @@ -202,6 +217,7 @@ async function loadAvailableMetadataProviders(bookId: number) { const res = await api(`/api/v1/metadata-fetch/providers?bookId=${bookId}`) if (!res.ok) return const providers = (await res.json()) as MetadataProviderInfo[] + registerProviderAppearance(providers) if (token === providerLoadToken) availableMetadataProviders.value = providers } catch { // non-fatal: keep all provider ID fields visible when provider availability cannot be loaded @@ -293,7 +309,8 @@ const filledProviderIdSummary = computed(() => { const value = form[entry.field] return typeof value === 'string' && value.trim().length > 0 }).length - return `${filled}/${visibleProviderIdFields.value.length}` + const filledExternal = externalProviderIdRows.value.filter((row) => (form.externalProviderIds[row.key] ?? '').trim().length > 0).length + return `${filled + filledExternal}/${visibleProviderIdFields.value.length + externalProviderIdRows.value.length}` }) const comicCreditFields = computed( @@ -575,6 +592,24 @@ function applyCustomMetadataPatch(formPatch: MetadataPatch): number { return updated } +/** An empty id is sent as null on save, which clears it. */ +function clearExternalProviderId(key: string) { + form.externalProviderIds = { ...form.externalProviderIds, [key]: '' } +} + +/** External ids have no lock, so a linked candidate's id always lands, with the page it came from. */ +function applyExternalProviderIdsPatch(formPatch: MetadataPatch): number { + let updated = 0 + for (const [key, id] of Object.entries(formPatch.externalProviderIds ?? {})) { + if (!id || form.externalProviderIds[key] === id) continue + form.externalProviderIds = { ...form.externalProviderIds, [key]: id } + const link = formPatch.externalProviderLinks?.[key] + if (link) form.externalProviderLinks = { ...form.externalProviderLinks, [key]: link } + updated++ + } + return updated +} + type FetchedCovers = Partial> function applyPatchToForm(formPatch: MetadataPatch, covers: FetchedCovers): { skippedFields: BookMetadataLockField[]; updatedCount: number } { @@ -591,6 +626,7 @@ function applyPatchToForm(formPatch: MetadataPatch, covers: FetchedCovers): { sk updatedCount += applyComicPatch(formPatch, skippedFields) updatedCount += applyAudioPatch(formPatch, skippedFields) updatedCount += applyCustomMetadataPatch(formPatch) + updatedCount += applyExternalProviderIdsPatch(formPatch) for (const [medium, url] of Object.entries(covers) as [CoverMedium, string | undefined][]) { if (!url) continue @@ -684,6 +720,13 @@ function showSaveResultToast(write: WriteResult | null, libraryAutoWriteEnabled: toast.info(reason ? t('book.detail.editMetadata.savedWriteSkippedReason', { reason }) : t('book.detail.editMetadata.savedWriteSkipped')) } +function externalIdsFromPreview(preview: MetadataRefreshPreview): MetadataPatch['externalProviderIds'] { + const ids = Object.entries(preview.externalProviderIds ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '', + ) + return ids.length > 0 ? Object.fromEntries(ids) : undefined +} + function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch { return { isbn10: preview.isbn10, @@ -716,6 +759,7 @@ function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch { ranobedbId: preview.ranobedbId, lubimyczytacId: preview.lubimyczytacId, aladinId: preview.aladinId, + externalProviderIds: externalIdsFromPreview(preview), comicMetadata: preview.comicMetadata, narrators: preview.audioMetadata?.narrators, durationSeconds: preview.audioMetadata?.durationSeconds ?? undefined, @@ -1354,7 +1398,10 @@ function handleCoverChanged(medium: CoverMedium | null) { -
+

{{ t('book.detail.editMetadata.providerIds') }} @@ -1398,6 +1445,37 @@ function handleCoverChanged(medium: CoverMedium | null) {

+
+ + + + +
diff --git a/client/src/features/book/composables/__tests__/useMetadataProviderLabels.spec.ts b/client/src/features/book/composables/__tests__/useMetadataProviderLabels.spec.ts new file mode 100644 index 000000000..bf10eafaa --- /dev/null +++ b/client/src/features/book/composables/__tests__/useMetadataProviderLabels.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' +import { refreshMetadataProviderLabels, useMetadataProviderLabels } from '../useMetadataProviderLabels' + +const responses: Array<{ status: number; body?: unknown }> = [] + +vi.mock('@/lib/api', () => ({ + api: vi.fn<() => Promise>(() => { + const next = responses.shift() ?? { status: 500 } + return Promise.resolve({ ok: next.status < 400, status: next.status, json: () => Promise.resolve(next.body) }) + }), +})) + +describe('useMetadataProviderLabels', () => { + it('asks again after an outage, and picks up providers added later on refresh', async () => { + responses.push({ status: 503 }) + const { labels } = useMetadataProviderLabels() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(labels.value).toEqual({}) + + responses.push({ status: 200, body: [{ key: 'fantlab', label: 'FantLab' }] }) + useMetadataProviderLabels() + await vi.waitFor(() => expect(labels.value).toEqual({ fantlab: 'FantLab' })) + + responses.push({ + status: 200, + body: [ + { key: 'fantlab', label: 'FantLab (fantlab:8080)' }, + { key: 'libris', label: 'Libris' }, + ], + }) + await refreshMetadataProviderLabels() + expect(labels.value).toEqual({ fantlab: 'FantLab (fantlab:8080)', libris: 'Libris' }) + }) +}) diff --git a/client/src/features/book/composables/useMetadataDiff.ts b/client/src/features/book/composables/useMetadataDiff.ts index 67036a448..37c0609d1 100644 --- a/client/src/features/book/composables/useMetadataDiff.ts +++ b/client/src/features/book/composables/useMetadataDiff.ts @@ -1,4 +1,5 @@ import { computed, ref, shallowReactive, toRaw, toValue, type MaybeRefOrGetter } from 'vue' +import { isKnownMetadataProviderKey } from '@bookorbit/types' import type { BookCommunityRating, BookMetadataLockField, @@ -21,6 +22,7 @@ import { FIELD_DEFS, LIST_FIELD_KEYS, PROVIDER_ID_FIELD, + providerIdFieldFor, classifyField, classifyIdentifier, descriptionPlainText, @@ -48,6 +50,7 @@ export { COMIC_KEY_MAP, FIELD_DEFS, PROVIDER_ID_FIELD, + providerIdFieldFor, getCandidateValueFrom, isComicDiffFieldKey, isProviderIdPatchField, @@ -167,6 +170,9 @@ export interface MetadataPatch { ranobedbId?: string | null lubimyczytacId?: string | null aladinId?: string | null + /** External providers' ids, keyed by provider, linked from the candidates that contributed. */ + externalProviderIds?: Partial> + externalProviderLinks?: Partial> comicMetadata?: ComicMetadataFields customMetadata?: CustomMetadataBookValueInput[] } @@ -339,7 +345,7 @@ export function useMetadataDiff(input: MetadataDiffInput) { } } - const idField = PROVIDER_ID_FIELD[active.provider] + const idField = providerIdFieldFor(active.provider) const existing = existingProviderId(toValue(input.providerIds), active.provider) const offered = active.providerId ?? '' if (idField && (offered || existing)) { @@ -651,10 +657,14 @@ export function useMetadataDiff(input: MetadataDiffInput) { // Link every provider that contributed a value, so a later fetch can find the same record. for (const candidate of contributors) { if (!candidate.providerId) continue - const idField = PROVIDER_ID_FIELD[candidate.provider] + const idField = providerIdFieldFor(candidate.provider) if (idField && formPatch[idField] === undefined && !lockedFieldSet.value.has(idField)) { formPatch[idField] = candidate.providerId } + if (!idField && !isKnownMetadataProviderKey(candidate.provider)) { + formPatch.externalProviderIds = { ...formPatch.externalProviderIds, [candidate.provider]: candidate.providerId } + if (candidate.sourceUrl) formPatch.externalProviderLinks = { ...formPatch.externalProviderLinks, [candidate.provider]: candidate.sourceUrl } + } if ( candidate.provider === 'hardcover' && candidate.hardcoverEditionId && @@ -669,7 +679,7 @@ export function useMetadataDiff(input: MetadataDiffInput) { const ids = toValue(input.providerIds) if (ids) { for (const [provider, id] of Object.entries(ids)) { - const idField = PROVIDER_ID_FIELD[provider as MetadataProviderKey] + const idField = providerIdFieldFor(provider) if (idField && formPatch[idField] === undefined && id) { formPatch[idField] = id } diff --git a/client/src/features/book/composables/useMetadataEditor.ts b/client/src/features/book/composables/useMetadataEditor.ts index 697fc5c7a..3e37717bc 100644 --- a/client/src/features/book/composables/useMetadataEditor.ts +++ b/client/src/features/book/composables/useMetadataEditor.ts @@ -3,6 +3,7 @@ import { api } from '@/lib/api' import { readApiErrorDetail } from '@/lib/api-error' import { FORMAT_TO_GROUP, + isKnownMetadataProviderKey, type BookCommunityRating, type BookDetail, type BookMetadataLockField, @@ -123,6 +124,26 @@ export type MetadataSaveFailure = { status: number | null } +/** The ids of external providers on a book: every key of `providerIds` without an id field of its own. */ +function externalIdsFromBook(book: BookDetail): Record { + return Object.fromEntries( + Object.entries(book.providerIds).filter( + (entry): entry is [string, string] => !isKnownMetadataProviderKey(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '', + ), + ) +} + +/** Only what changed: a new or edited id as itself, a removed or emptied one as null. */ +function changedExternalProviderIds(current: Record, previous: Record): Record | null { + const changes: Record = {} + for (const key of new Set([...Object.keys(current), ...Object.keys(previous)])) { + const next = current[key]?.trim() ?? '' + if (next === (previous[key] ?? '')) continue + changes[key] = next || null + } + return Object.keys(changes).length > 0 ? changes : null +} + export function useMetadataEditor() { const saving = ref(false) const error = ref(null) @@ -163,6 +184,9 @@ export function useMetadataEditor() { ranobedbId: null as string | null, lubimyczytacId: null as string | null, aladinId: null as string | null, + /** External providers' ids, keyed by provider. An empty string clears one on save. */ + externalProviderIds: {} as Record, + externalProviderLinks: {} as Record, comicIssueNumber: null as string | null, comicVolumeName: null as string | null, comicStoryArcs: [] as string[], @@ -219,6 +243,10 @@ export function useMetadataEditor() { form.ranobedbId = book.providerIds.ranobedb ?? null form.lubimyczytacId = book.providerIds.lubimyczytac ?? null form.aladinId = book.providerIds.aladin ?? null + form.externalProviderIds = externalIdsFromBook(book) + form.externalProviderLinks = Object.fromEntries( + Object.entries(book.providerLinks ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ) const cm = book.comicMetadata form.comicIssueNumber = cm?.issueNumber ?? null form.comicVolumeName = cm?.volumeName ?? null @@ -295,6 +323,15 @@ export function useMetadataEditor() { } } + const externalIds = changedExternalProviderIds(form.externalProviderIds, previous.externalProviderIds) + if (externalIds) { + payload.externalProviderIds = externalIds + const links = Object.fromEntries( + Object.keys(externalIds).flatMap((key) => (form.externalProviderLinks[key] ? [[key, form.externalProviderLinks[key]]] : [])), + ) + if (Object.keys(links).length > 0) payload.externalProviderLinks = links + } + const customMetadata = changedCustomMetadataPayload(form.customMetadata, previous.customMetadata) if (customMetadata.length > 0) { payload.customMetadata = customMetadata diff --git a/client/src/features/book/composables/useMetadataProviderLabels.ts b/client/src/features/book/composables/useMetadataProviderLabels.ts new file mode 100644 index 000000000..01e38e9af --- /dev/null +++ b/client/src/features/book/composables/useMetadataProviderLabels.ts @@ -0,0 +1,43 @@ +import { ref } from 'vue' +import type { MetadataProviderInfo } from '@bookorbit/types' +import { api } from '@/lib/api' +import { registerProviderAppearance } from '@/lib/provider-appearance' + +const labels = ref>>({}) +let loading: Promise | null = null + +function load(): Promise { + return (async () => { + try { + const res = await api('/api/v1/metadata-fetch/providers') + // A refusal stands for the session; an outage is worth asking again. + if (res.status >= 500) throw new Error(`provider list answered ${res.status}`) + if (!res.ok) return + const providers = (await res.json()) as MetadataProviderInfo[] + labels.value = Object.fromEntries(providers.map((provider) => [provider.key, provider.label])) + registerProviderAppearance(providers) + } catch { + // Non-fatal: callers fall back to the provider key. The next caller tries again. + loading = null + } + })() +} + +/** + * Re-reads the names after providers were added, removed or renamed, so screens already open pick + * up the change without a reload. + */ +export function refreshMetadataProviderLabels(): Promise { + loading = load() + return loading +} + +/** + * Display names for every metadata provider the server knows, external ones included. Built-ins + * have client-side names already; this is how a provider registered at runtime gets its own name + * on screens that never load the provider list themselves. Loaded once, then on every refresh. + */ +export function useMetadataProviderLabels() { + if (!loading) loading = load() + return { labels } +} diff --git a/client/src/features/book/composables/useMetadataSearch.ts b/client/src/features/book/composables/useMetadataSearch.ts index c83271956..150833a5a 100644 --- a/client/src/features/book/composables/useMetadataSearch.ts +++ b/client/src/features/book/composables/useMetadataSearch.ts @@ -1,5 +1,6 @@ import { computed, onUnmounted, reactive, ref, shallowReactive } from 'vue' import { api } from '@/lib/api' +import { registerProviderAppearance } from '@/lib/provider-appearance' import { METADATA_PROVIDER_STATUS_EVENT } from '@bookorbit/types' import type { ConcreteBookMediaKind, @@ -57,6 +58,7 @@ export function useMetadataSearch() { const res = await api(`/api/v1/metadata-fetch/providers${query}`) if (res.ok) { providers.value = (await res.json()) as MetadataProviderInfo[] + registerProviderAppearance(providers.value) selectAllProviders() } } diff --git a/client/src/features/book/lib/__tests__/metadata-diff-fields.spec.ts b/client/src/features/book/lib/__tests__/metadata-diff-fields.spec.ts index 400781917..b73d979c3 100644 --- a/client/src/features/book/lib/__tests__/metadata-diff-fields.spec.ts +++ b/client/src/features/book/lib/__tests__/metadata-diff-fields.spec.ts @@ -11,6 +11,7 @@ import { classifyRating, classifyText, descriptionPlainText, + providerIdFieldFor, summarizeDifferences, } from '../metadata-diff-fields' @@ -109,3 +110,12 @@ describe('summarizeDifferences', () => { expect(summarizeDifferences(current, candidate, { amazon: 'B002KP6DXQ' })).toEqual({ fills: 1, changes: 2 }) }) }) + +describe('providerIdFieldFor', () => { + it('keeps the id field of a source that moved out, and gives other external providers none', () => { + expect(providerIdFieldFor('google')).toBe('googleBooksId') + expect(providerIdFieldFor('lubimyczytac')).toBe('lubimyczytacId') + expect(providerIdFieldFor('aladin')).toBe('aladinId') + expect(providerIdFieldFor('fantlab')).toBeUndefined() + }) +}) diff --git a/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts b/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts index 353c584ee..d19ddd23f 100644 --- a/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts +++ b/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { MetadataProviderKey, type BookDetail, type MetadataFetchDiagnostics } from '@bookorbit/types' -import { metadataRefreshAppliedMessage, metadataRefreshEmptyMessage } from '../metadata-refresh-feedback' +import { metadataProviderLabel, metadataRefreshAppliedMessage, metadataRefreshEmptyMessage } from '../metadata-refresh-feedback' function makeBook(overrides: Partial = {}): BookDetail { return { @@ -136,3 +136,11 @@ describe('metadataRefreshAppliedMessage', () => { ) }) }) + +describe('metadataProviderLabel', () => { + it('names built-in sources and the ones that moved out, and falls back to the key', () => { + expect(metadataProviderLabel('openLibrary')).toBe('Open Library') + expect(metadataProviderLabel('lubimyczytac')).toBe('LubimyCzytac') + expect(metadataProviderLabel('fantlab')).toBe('fantlab') + }) +}) diff --git a/client/src/features/book/lib/filter-labels.ts b/client/src/features/book/lib/filter-labels.ts index 30b6e4d34..47dfe76a3 100644 --- a/client/src/features/book/lib/filter-labels.ts +++ b/client/src/features/book/lib/filter-labels.ts @@ -2,7 +2,7 @@ import type { Rule, RuleField, RuleOperator, SortField } from '@bookorbit/types' import { isReadAlongFormatKey, parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types' import { i18n } from '@/i18n' import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields' -import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' +import { providerShortLabel } from '@/lib/provider-colors' import { formatBytes } from '@/lib/formatting' import { formatKeyName } from './book-formats' @@ -48,7 +48,7 @@ function communityRatingProviderLabel(rule: Rule): string { if (rule.field !== 'communityRating' && rule.field !== 'communityRatingCount') return '' const provider = rule.provider ?? 'any' if (provider === 'any') return t('book.filter.anyProvider') - return PROVIDER_SHORT_LABELS[provider] ?? provider + return providerShortLabel(provider) } export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } { diff --git a/client/src/features/book/lib/metadata-diff-fields.ts b/client/src/features/book/lib/metadata-diff-fields.ts index a8943f678..f5fdec454 100644 --- a/client/src/features/book/lib/metadata-diff-fields.ts +++ b/client/src/features/book/lib/metadata-diff-fields.ts @@ -1,4 +1,6 @@ +import { isKnownMetadataProviderKey } from '@bookorbit/types' import type { + KnownMetadataProviderKey, ComicMetadataFields, MetadataCandidate, MetadataProviderKey, @@ -118,7 +120,11 @@ export function isComicDiffFieldKey(key: DiffFieldKey): key is ComicDiffFieldKey return key in COMIC_KEY_MAP } -export const PROVIDER_ID_FIELD: Record = { +/** + * The providers with an id field of their own: the built-ins, and the ones that moved out but kept + * theirs. Other external providers' ids travel in `externalProviderIds` instead. + */ +export const PROVIDER_ID_FIELD: Record = { google: 'googleBooksId', goodreads: 'goodreadsId', amazon: 'amazonId', @@ -137,6 +143,11 @@ export const PROVIDER_ID_FIELD: Record(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined)) +/** The named id field a provider's id lives in; undefined for an external provider without one. */ +export function providerIdFieldFor(provider: MetadataProviderKey): ProviderIdPatchField | undefined { + return isKnownMetadataProviderKey(provider) ? PROVIDER_ID_FIELD[provider] : undefined +} + export function isProviderIdPatchField(key: DiffFieldKey): key is ProviderIdPatchField { return PROVIDER_ID_PATCH_FIELDS.has(key) } @@ -480,7 +491,7 @@ export function summarizeDifferences(current: MetadataSource, candidate: Metadat if (candidate.comicMetadata) { for (const def of COMIC_FIELD_DEFS) count(classifyField(def.key, current, candidate).kind) } - if (PROVIDER_ID_FIELD[candidate.provider] && candidate.providerId) { + if (providerIdFieldFor(candidate.provider) && candidate.providerId) { count(classifyIdentifier(existingProviderId(providerIds, candidate.provider), candidate.providerId).kind) } return summary diff --git a/client/src/features/book/lib/metadata-refresh-feedback.ts b/client/src/features/book/lib/metadata-refresh-feedback.ts index be72b2543..4f767bc0a 100644 --- a/client/src/features/book/lib/metadata-refresh-feedback.ts +++ b/client/src/features/book/lib/metadata-refresh-feedback.ts @@ -1,30 +1,19 @@ import { - MetadataProviderKey, + BUILT_IN_METADATA_PROVIDER_LABELS, + isMovedMetadataProviderKey, + MOVED_METADATA_PROVIDERS, type BookDetail, + type BuiltInMetadataProviderKey, type MetadataFetchDiagnostics, - type MetadataProviderKey as MetadataProviderKeyType, } from '@bookorbit/types' +import type { MetadataProviderKey as MetadataProviderKeyType } from '@bookorbit/types' -const PROVIDER_LABELS: Record = { - [MetadataProviderKey.GOOGLE]: 'Google Books', - [MetadataProviderKey.GOODREADS]: 'Goodreads', - [MetadataProviderKey.AMAZON]: 'Amazon', - [MetadataProviderKey.HARDCOVER]: 'Hardcover', - [MetadataProviderKey.OPEN_LIBRARY]: 'Open Library', - [MetadataProviderKey.ITUNES]: 'iTunes', - [MetadataProviderKey.AUDIBLE]: 'Audible', - [MetadataProviderKey.AUDNEXUS]: 'AudNexus', - [MetadataProviderKey.LIBROFM]: 'Libro.fm', - [MetadataProviderKey.COMICVINE]: 'ComicVine', - [MetadataProviderKey.RANOBEDB]: 'RanobeDB', - [MetadataProviderKey.KOBO]: 'Kobo', - [MetadataProviderKey.LUBIMYCZYTAC]: 'LubimyCzytac', - [MetadataProviderKey.ALADIN]: 'Aladin', -} +const PROVIDER_LABELS = BUILT_IN_METADATA_PROVIDER_LABELS /** A display name for a provider key, including the author providers that share the book registry's names. */ export function metadataProviderLabel(provider: string): string { - return PROVIDER_LABELS[provider as MetadataProviderKeyType] ?? provider + if (isMovedMetadataProviderKey(provider)) return MOVED_METADATA_PROVIDERS[provider].label + return PROVIDER_LABELS[provider as BuiltInMetadataProviderKey] ?? provider } export function metadataRefreshEmptyMessage(diagnostics: MetadataFetchDiagnostics, book: BookDetail): string { @@ -86,7 +75,7 @@ function bookSearchLabel(book: BookDetail): string { } function formatProviderList(providers: MetadataProviderKeyType[]): string { - return formatTextList(providers.map((provider) => PROVIDER_LABELS[provider] ?? provider)) + return formatTextList(providers.map((provider) => metadataProviderLabel(provider))) } function formatTextList(items: string[]): string { diff --git a/client/src/features/book/lib/provider-icons.ts b/client/src/features/book/lib/provider-icons.ts index 0cc21d083..b81eae0e3 100644 --- a/client/src/features/book/lib/provider-icons.ts +++ b/client/src/features/book/lib/provider-icons.ts @@ -1,3 +1,5 @@ +import { providerAppearance } from '@/lib/provider-appearance' + const PROVIDER_ICON_BASE_PATH = '/assets/provider-icons' const PROVIDER_ICON_PATHS = { @@ -21,6 +23,9 @@ export function providerIconPath(provider: ProviderIconKey): string { return PROVIDER_ICON_PATHS[provider] } +/** A built-in's bundled icon, else the icon BookOrbit serves for an external provider, else null. */ export function providerIconPathSafe(provider: string): string | null { - return Object.prototype.hasOwnProperty.call(PROVIDER_ICON_PATHS, provider) ? PROVIDER_ICON_PATHS[provider as ProviderIconKey] : null + return Object.prototype.hasOwnProperty.call(PROVIDER_ICON_PATHS, provider) + ? PROVIDER_ICON_PATHS[provider as ProviderIconKey] + : (providerAppearance(provider)?.iconUrl ?? null) } diff --git a/client/src/features/book/lib/provider-links.ts b/client/src/features/book/lib/provider-links.ts index a9168eb59..f4f0a86c3 100644 --- a/client/src/features/book/lib/provider-links.ts +++ b/client/src/features/book/lib/provider-links.ts @@ -1,4 +1,4 @@ -import { MetadataProviderKey, type ProviderIds } from '@bookorbit/types' +import { isKnownMetadataProviderKey, MetadataProviderKey, type ProviderIds } from '@bookorbit/types' import { normalizeAmazonDomain } from '@/lib/amazon-domain' import { providerIconPathSafe } from './provider-icons' @@ -141,3 +141,20 @@ export function createBookProviderLinks(providerIds: ProviderIds, settings: Prov ] }) } + +/** + * Links for external providers' ids. The server sends the page each id was applied from, or one + * built from the provider's own template, so nothing here knows any external provider. + */ +export function createExternalProviderLinks( + providerIds: ProviderIds, + links: Partial> | undefined, + labels: Partial> = {}, +): BookProviderLink[] { + return Object.entries(providerIds).flatMap(([key, id]) => { + const url = links?.[key] + if (isKnownMetadataProviderKey(key) || !id?.trim() || !url) return [] + const label = labels[key] ?? key + return [{ key, label, url, iconUrl: providerIconPathSafe(key), fallback: label.slice(0, 2).toUpperCase() }] + }) +} diff --git a/client/src/features/settings/components/ExternalTextLink.vue b/client/src/features/settings/components/ExternalTextLink.vue new file mode 100644 index 000000000..ad2415c92 --- /dev/null +++ b/client/src/features/settings/components/ExternalTextLink.vue @@ -0,0 +1,24 @@ + + + diff --git a/client/src/features/settings/components/SettingsTabs.spec.ts b/client/src/features/settings/components/SettingsTabs.spec.ts index 8368757d5..bba41771b 100644 --- a/client/src/features/settings/components/SettingsTabs.spec.ts +++ b/client/src/features/settings/components/SettingsTabs.spec.ts @@ -45,4 +45,20 @@ describe('SettingsTabs', () => { await wrapper.get('button').trigger('click') expect(wrapper.emitted('select')).toEqual([['display']]) }) + + it('marks a tab that has something waiting, in words a screen reader can say', () => { + const wrapper = mount(SettingsTabs, { + props: { + tabs: [ + { id: 'quiet', label: 'Quiet' }, + { id: 'busy', label: 'Busy', count: 3, attention: 'Needs attention' }, + ], + activeTab: 'quiet', + }, + }) + + const [quiet, busy] = wrapper.findAll('[role="tab"]') + expect(quiet!.find('[role="img"]').exists()).toBe(false) + expect(busy!.get('[role="img"]').attributes('aria-label')).toBe('Needs attention') + }) }) diff --git a/client/src/features/settings/components/SettingsTabs.vue b/client/src/features/settings/components/SettingsTabs.vue index 6a095aba3..b6d83b60d 100644 --- a/client/src/features/settings/components/SettingsTabs.vue +++ b/client/src/features/settings/components/SettingsTabs.vue @@ -9,6 +9,8 @@ interface SettingsTabItem { controls?: string /** A quiet count after the label, such as how many items the tab holds. */ count?: number + /** Marks a tab with something waiting in it; the text is what a screen reader hears. */ + attention?: string } const props = withDefaults( @@ -85,7 +87,13 @@ function handleSelect(tab: T): void { {{ tab.label }}{{ formatNumber(tab.count) - }} + }}
-import { onMounted, onUnmounted } from 'vue' +import { computed, onMounted, onUnmounted } from 'vue' +import { useI18n } from 'vue-i18n' import type { ProviderConfigurations } from '@bookorbit/types' +import SettingsTabs from '@/features/settings/components/SettingsTabs.vue' +import { useRouteTab } from '@/composables/useRouteTab' +import ExternalProvidersTab from './components/ExternalProvidersTab.vue' import ProviderConfigPanel from './components/ProviderConfigPanel.vue' import { useProviderConfig } from './composables/useProviderConfig' import { useProviderThrottleRuntime } from './composables/useProviderThrottleRuntime' +const { t } = useI18n() + const { config, statuses, saving, testingByKey, testResultsByKey, passingTestSignatureByKey, fetchConfig, saveConfig, testProvider } = useProviderConfig() const { runtimeByKey, startPolling, stopPolling } = useProviderThrottleRuntime() +/** + * Sources BookOrbit ships with, and the ones an admin runs beside it. They are configured in + * different ways, one with switches and keys, the other by address, so they read best apart. + */ +const TABS = ['built-in', 'external'] as const +type Tab = (typeof TABS)[number] + +function normalizeTab(value: unknown): Tab { + return typeof value === 'string' && TABS.includes(value as Tab) ? (value as Tab) : 'built-in' +} + +const { activeTab, selectTab } = useRouteTab({ + routeName: 'settings-metadata-providers', + normalize: normalizeTab, + availableTabs: TABS, + fallback: 'built-in', +}) + +const tabs = computed(() => { + const external = statuses.value.filter((status) => status.source === 'external' && !status.movedOut) + const needsAttention = statuses.value.some((status) => status.movedOut?.inUse) + return [ + { + id: 'built-in' as const, + label: t('settings.metadata.providers.tabs.builtIn'), + count: statuses.value.length ? statuses.value.filter((status) => status.source !== 'external').length : undefined, + }, + { + id: 'external' as const, + label: t('settings.metadata.providers.tabs.external'), + count: statuses.value.length ? external.length : undefined, + attention: needsAttention ? t('settings.metadata.providers.tabs.attention') : undefined, + }, + ] +}) + onMounted(() => { startPolling() void fetchConfig().catch(() => undefined) @@ -17,10 +59,18 @@ onMounted(() => { onUnmounted(() => { stopPolling() }) + +function handleExternalProvidersChanged() { + void fetchConfig().catch(() => undefined) +} diff --git a/client/src/features/settings/metadata-preferences/__tests__/ExternalProviderSheet.spec.ts b/client/src/features/settings/metadata-preferences/__tests__/ExternalProviderSheet.spec.ts new file mode 100644 index 000000000..35f438cfa --- /dev/null +++ b/client/src/features/settings/metadata-preferences/__tests__/ExternalProviderSheet.spec.ts @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { describe, expect, it } from 'vitest' +import ExternalProviderSheet from '../components/ExternalProviderSheet.vue' + +// The real sheet portals into a reka dialog; the stub renders its slots in place and exposes `dirty`. +const SheetStub = defineComponent({ + name: 'SettingsEditorSheet', + props: ['open', 'title', 'description', 'dirty', 'busy', 'removable', 'removeLabel', 'removeConfirm', 'removeConsequence', 'menuActions'], + emits: ['save', 'cancel', 'remove', 'action'], + setup(props, { slots }) { + return () => h('div', { 'data-dirty': String(props.dirty) }, [slots.status?.(), slots.default?.(), slots.footer?.({ requestClose: () => {} })]) + }, +}) + +function mountSheet(prefill: { baseUrl: string; allowPrivateAddress: boolean } | null) { + return mount(ExternalProviderSheet, { + props: { open: true, provider: null, busy: false, testing: false, testResult: null, error: null, prefill }, + global: { stubs: { SettingsEditorSheet: SheetStub } }, + }) +} + +describe('ExternalProviderSheet', () => { + it('starts from a ready-made provider without counting its address as an edit', async () => { + const wrapper = mountSheet({ baseUrl: 'http://libris:8080', allowPrivateAddress: true }) + + expect((wrapper.get('input[type="url"]').element as HTMLInputElement).value).toBe('http://libris:8080') + expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe('true') + expect(wrapper.get('[data-dirty]').attributes('data-dirty')).toBe('false') + + await wrapper.get('input[type="url"]').setValue('http://libris:9000') + expect(wrapper.get('[data-dirty]').attributes('data-dirty')).toBe('true') + }) + + it('starts empty, with private addresses refused, when added by hand', () => { + const wrapper = mountSheet(null) + + expect((wrapper.get('input[type="url"]').element as HTMLInputElement).value).toBe('') + expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe('false') + }) +}) diff --git a/client/src/features/settings/metadata-preferences/__tests__/MovedProvidersNotice.spec.ts b/client/src/features/settings/metadata-preferences/__tests__/MovedProvidersNotice.spec.ts new file mode 100644 index 000000000..2a21a1847 --- /dev/null +++ b/client/src/features/settings/metadata-preferences/__tests__/MovedProvidersNotice.spec.ts @@ -0,0 +1,31 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import type { ProviderStatus } from '@bookorbit/types' +import MovedProvidersNotice from '../components/MovedProvidersNotice.vue' + +function moved(key: string, label: string, inUse: boolean, image: string | null = `ghcr.io/bookorbit/${key}-provider`): ProviderStatus { + return { key, label, enabled: false, configured: false, source: 'external', movedOut: { image, inUse } } +} + +const GOOGLE: ProviderStatus = { key: 'google', label: 'Google Books', enabled: true, configured: true, source: 'builtin' } + +describe('MovedProvidersNotice', () => { + it('says nothing when no moved source is still in use', () => { + const wrapper = mount(MovedProvidersNotice, { props: { statuses: [GOOGLE, moved('lubimyczytac', 'LubimyCzytac', false)] } }) + + expect(wrapper.find('[role="status"]').exists()).toBe(false) + }) + + it('names the image to run for a source that moved, and says a closed source cannot come back', () => { + const wrapper = mount(MovedProvidersNotice, { + props: { statuses: [GOOGLE, moved('lubimyczytac', 'LubimyCzytac', true), moved('aladin', 'Aladin', true, null)] }, + }) + + const items = wrapper.findAll('li') + expect(items).toHaveLength(2) + expect(items[0]!.text()).toContain('LubimyCzytac') + expect(items[0]!.find('code').text()).toBe('ghcr.io/bookorbit/lubimyczytac-provider') + expect(items[1]!.text()).toContain('Aladin') + expect(items[1]!.find('code').exists()).toBe(false) + }) +}) diff --git a/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanel.spec.ts b/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanel.spec.ts index 97cd30ffe..66ee4dc27 100644 --- a/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanel.spec.ts +++ b/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanel.spec.ts @@ -26,19 +26,11 @@ function makeConfig(overrides: Partial = {}): ProviderCo comicvine: { enabled: true, apiKey: 'comicvine-secret-key' }, ranobedb: { enabled: false }, kobo: { enabled: false, country: 'us', language: 'en' }, - lubimyczytac: { enabled: false }, - aladin: { enabled: true, ttbKey: 'aladin-ttb-key' }, ...overrides, } } -const SECRET_FIELD_NAMES = [ - 'metadata-google-apiKey', - 'metadata-amazon-cookie', - 'metadata-hardcover-apiKey', - 'metadata-comicvine-apiKey', - 'metadata-aladin-ttbKey', -] +const SECRET_FIELD_NAMES = ['metadata-google-apiKey', 'metadata-amazon-cookie', 'metadata-hardcover-apiKey', 'metadata-comicvine-apiKey'] function mountPanel(config: ProviderConfigurations = makeConfig(), statuses: ProviderStatus[] = []) { return mount(ProviderConfigPanel, { props: { config, statuses, saving: false } }) @@ -102,7 +94,6 @@ describe('ProviderConfigPanel secret fields', () => { expect((secretField(wrapper, 'metadata-google-apiKey').element as HTMLInputElement).value).toBe('google-secret-key') expect((secretField(wrapper, 'metadata-amazon-cookie').element as HTMLInputElement).value).toBe('amazon-session-cookie') - expect((secretField(wrapper, 'metadata-aladin-ttbKey').element as HTMLInputElement).value).toBe('aladin-ttb-key') }) it('leaves non-secret fields unmasked', () => { diff --git a/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanelControls.spec.ts b/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanelControls.spec.ts index 8194d42bc..e4cc4825e 100644 --- a/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanelControls.spec.ts +++ b/client/src/features/settings/metadata-preferences/__tests__/ProviderConfigPanelControls.spec.ts @@ -25,8 +25,6 @@ function makeConfig(overrides: Partial = {}): ProviderCo comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, kobo: { enabled: false, country: 'us', language: 'en' }, - lubimyczytac: { enabled: false }, - aladin: { enabled: false, ttbKey: '' }, ...overrides, } } @@ -48,8 +46,6 @@ function makeStatuses(): ProviderStatus[] { configured('comicvine', false, false), configured('ranobedb', false), configured('kobo', false), - configured('lubimyczytac', false), - configured('aladin', false, false), ] } @@ -95,7 +91,7 @@ describe('ProviderConfigPanel browsing', () => { const wrapper = mountPanel() const headings = wrapper.findAll('h3').map((heading) => heading.text()) - expect(headings).toEqual(['General book catalogues', 'Audiobooks', 'Comics & light novels', 'Regional catalogues']) + expect(headings).toEqual(['General book catalogues', 'Audiobooks', 'Comics & light novels']) wrapper.unmount() }) @@ -113,7 +109,7 @@ describe('ProviderConfigPanel browsing', () => { await clickFilter(wrapper, 'Needs setup') - expect(providerNames(wrapper)).toEqual(['Hardcover', 'ComicVine', 'Aladin']) + expect(providerNames(wrapper)).toEqual(['Hardcover', 'ComicVine']) wrapper.unmount() }) @@ -124,8 +120,8 @@ describe('ProviderConfigPanel browsing', () => { await search.setValue('kobo') expect(providerNames(wrapper)).toEqual(['Kobo']) - await search.setValue('korean') - expect(providerNames(wrapper)).toEqual(['Aladin']) + await search.setValue('japanese') + expect(providerNames(wrapper)).toEqual(['RanobeDB']) await search.setValue('nothing matches this') expect(wrapper.findAll('[role="switch"]')).toHaveLength(0) @@ -133,7 +129,7 @@ describe('ProviderConfigPanel browsing', () => { const clear = wrapper.findAll('button').find((candidate) => candidate.text() === 'Clear filters') await clear?.trigger('click') - expect(wrapper.findAll('[role="switch"]')).toHaveLength(14) + expect(wrapper.findAll('[role="switch"]')).toHaveLength(12) wrapper.unmount() }) }) diff --git a/client/src/features/settings/metadata-preferences/__tests__/ProviderDirectoryPanel.spec.ts b/client/src/features/settings/metadata-preferences/__tests__/ProviderDirectoryPanel.spec.ts new file mode 100644 index 000000000..379fe9fa2 --- /dev/null +++ b/client/src/features/settings/metadata-preferences/__tests__/ProviderDirectoryPanel.spec.ts @@ -0,0 +1,117 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { MetadataProviderDirectory, MetadataProviderDirectoryEntry } from '@bookorbit/types' +import ProviderDirectoryPanel from '../components/ProviderDirectoryPanel.vue' +import { composeServiceFor } from '../composables/useProviderDirectory' + +const api = vi.hoisted(() => vi.fn<(url: string) => Promise>()) +const copyToClipboard = vi.hoisted(() => vi.fn<(text: string) => Promise>()) +const toastSuccess = vi.hoisted(() => vi.fn<(message: string) => void>()) +const toastError = vi.hoisted(() => vi.fn<(message: string) => void>()) + +vi.mock('@/lib/api', () => ({ api })) +vi.mock('@/lib/clipboard', () => ({ copyToClipboard })) +vi.mock('vue-sonner', () => ({ toast: { success: toastSuccess, error: toastError } })) + +function entry(key: string, name: string, overrides: Partial = {}): MetadataProviderDirectoryEntry { + return { + key, + name, + summary: `${name} summary.`, + image: `ghcr.io/bookorbit/${key}-provider`, + version: '0.1.0', + port: 8080, + docsUrl: `https://github.com/bookorbit/bookorbit-metadata-providers/tree/main/providers/${key}`, + homepage: null, + color: '#336699', + mediaTypes: ['book'], + languages: [], + ...overrides, + } +} + +const DIRECTORY: MetadataProviderDirectory = { + source: 'bundled', + fetchedAt: null, + providers: [ + entry('libris', 'Libris', { mediaTypes: ['book', 'audiobook'], languages: ['sv'] }), + entry('lubimyczytac', 'LubimyCzytac', { port: 9090 }), + entry('fantlab', 'FantLab'), + ], +} + +async function mountPanel(registeredKeys: string[] = ['fantlab']) { + const wrapper = mount(ProviderDirectoryPanel, { props: { registeredKeys } }) + await flushPromises() + return wrapper +} + +beforeEach(() => { + api.mockReset().mockResolvedValue(Response.json(DIRECTORY)) + copyToClipboard.mockReset().mockResolvedValue(true) + toastSuccess.mockReset() + toastError.mockReset() +}) + +describe('ProviderDirectoryPanel', () => { + it('offers only what is not added yet, and names the rest on one line', async () => { + const wrapper = await mountPanel() + + expect(wrapper.findAll('li h4').map((heading) => heading.text())).toEqual(['Libris', 'LubimyCzytac']) + expect(wrapper.text()).toContain('Already added: FantLab') + expect(wrapper.text()).toContain('List included with this version') + }) + + it('says what each covers, and marks a source that used to be built in', async () => { + const wrapper = await mountPanel() + const [libris, lubimyczytac] = wrapper.findAll('li') + + expect(libris!.text()).toContain('Books, Audiobooks, Swedish') + expect(libris!.find('code').text()).toBe('ghcr.io/bookorbit/libris-provider:0.1.0') + expect(lubimyczytac!.text()).toContain('Was built in') + expect(libris!.text()).not.toContain('Was built in') + }) + + it('copies a Compose service that fits under services:', async () => { + const wrapper = await mountPanel() + + await wrapper.get('button[aria-label="Copy the Compose service for Libris"]').trigger('click') + await flushPromises() + + expect(copyToClipboard).toHaveBeenCalledWith(' libris:\n image: ghcr.io/bookorbit/libris-provider:0.1.0\n restart: unless-stopped\n') + expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Libris copied')) + }) + + it('asks for the address the Compose service answers on, with private addresses allowed', async () => { + const wrapper = await mountPanel() + + await wrapper.get('button[aria-label="Add LubimyCzytac"]').trigger('click') + + expect(wrapper.emitted('add')).toEqual([[{ baseUrl: 'http://lubimyczytac:9090', allowPrivateAddress: true }]]) + }) + + it('says when every provider is already added', async () => { + const wrapper = await mountPanel(['libris', 'lubimyczytac', 'fantlab']) + + expect(wrapper.findAll('li h4')).toHaveLength(0) + expect(wrapper.text()).toContain('You have added every provider in this list.') + }) + + it('still points at adding by address when the list cannot be loaded', async () => { + api.mockResolvedValue(new Response('nope', { status: 500 })) + const wrapper = await mountPanel() + + expect(wrapper.get('[role="alert"]').text()).toContain('You can still add any provider by its address') + }) + + it('reports a copy the browser refused', async () => { + copyToClipboard.mockResolvedValue(false) + const wrapper = await mountPanel() + + await wrapper.get('button[aria-label="Copy the Compose service for Libris"]').trigger('click') + await flushPromises() + + expect(toastError).toHaveBeenCalled() + expect(composeServiceFor(DIRECTORY.providers[0]!)).toContain('libris-provider:0.1.0') + }) +}) diff --git a/client/src/features/settings/metadata-preferences/components/BuildYourOwnProviderCard.vue b/client/src/features/settings/metadata-preferences/components/BuildYourOwnProviderCard.vue new file mode 100644 index 000000000..8d80a3653 --- /dev/null +++ b/client/src/features/settings/metadata-preferences/components/BuildYourOwnProviderCard.vue @@ -0,0 +1,35 @@ + + + diff --git a/client/src/features/settings/metadata-preferences/components/ExternalProviderSheet.vue b/client/src/features/settings/metadata-preferences/components/ExternalProviderSheet.vue new file mode 100644 index 000000000..8240f292f --- /dev/null +++ b/client/src/features/settings/metadata-preferences/components/ExternalProviderSheet.vue @@ -0,0 +1,269 @@ + + + diff --git a/client/src/features/settings/metadata-preferences/components/ExternalProvidersPanel.vue b/client/src/features/settings/metadata-preferences/components/ExternalProvidersPanel.vue new file mode 100644 index 000000000..73e25fe50 --- /dev/null +++ b/client/src/features/settings/metadata-preferences/components/ExternalProvidersPanel.vue @@ -0,0 +1,304 @@ + + + diff --git a/client/src/features/settings/metadata-preferences/components/ExternalProvidersTab.vue b/client/src/features/settings/metadata-preferences/components/ExternalProvidersTab.vue new file mode 100644 index 000000000..3e5e625e1 --- /dev/null +++ b/client/src/features/settings/metadata-preferences/components/ExternalProvidersTab.vue @@ -0,0 +1,36 @@ + + + diff --git a/client/src/features/settings/metadata-preferences/components/FieldRuleDetail.vue b/client/src/features/settings/metadata-preferences/components/FieldRuleDetail.vue index 7c7110fca..80fc777dc 100644 --- a/client/src/features/settings/metadata-preferences/components/FieldRuleDetail.vue +++ b/client/src/features/settings/metadata-preferences/components/FieldRuleDetail.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n' import { ArrowRight, ChevronDown, ChevronUp, RotateCcw, TriangleAlert, X } from '@lucide/vue' import type { FieldPreference, MetadataField, MetadataMergeStrategy, MetadataProviderKey, ProviderStatus } from '@bookorbit/types' import { Button } from '@/components/ui/button' -import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' +import { providerChipStyle, providerShortLabel } from '@/lib/provider-colors' import ProviderAddMenu from './ProviderAddMenu.vue' import { providerHasOtherShape, resolvedProviders, skippedProviders, skipReasonFor } from '../lib/field-rules' @@ -37,9 +37,9 @@ const rows = computed(() => key, index, label: status?.label ?? key, - short: PROVIDER_SHORT_LABELS[key] ?? key, + short: providerShortLabel(key, status?.label), skipReason: skipReasonFor(status, props.field, key), - otherShape: providerHasOtherShape(key, props.field), + otherShape: providerHasOtherShape(key, props.field, status?.supportedFields), } }), ) @@ -221,7 +221,7 @@ function handleClose() {
diff --git a/client/src/features/settings/metadata-preferences/components/FieldRuleRow.vue b/client/src/features/settings/metadata-preferences/components/FieldRuleRow.vue index f6d7a082c..69fa471b0 100644 --- a/client/src/features/settings/metadata-preferences/components/FieldRuleRow.vue +++ b/client/src/features/settings/metadata-preferences/components/FieldRuleRow.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n' import { ChevronDown, ChevronRight, TriangleAlert } from '@lucide/vue' import type { FieldPreference, MetadataField, MetadataMergeStrategy, MetadataProviderKey, ProviderStatus } from '@bookorbit/types' import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' -import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' +import { providerChipStyle, providerShortLabel as shortLabelFor } from '@/lib/provider-colors' import { Badge } from '@/components/ui/badge' import ProviderChipList from './ProviderChipList.vue' import ProviderAddMenu from './ProviderAddMenu.vue' @@ -46,7 +46,7 @@ const detailId = computed(() => `field-rule-detail-${props.field}`) const mergeSummary = computed(() => t(`settings.metadata.mergeStrategy.${props.preference.mergeStrategy}.label`)) function providerShortLabel(key: MetadataProviderKey): string { - return PROVIDER_SHORT_LABELS[key] ?? key + return shortLabelFor(key, props.statuses.find((entry) => entry.key === key)?.label) } function update(patch: Partial) { diff --git a/client/src/features/settings/metadata-preferences/components/MovedProvidersNotice.vue b/client/src/features/settings/metadata-preferences/components/MovedProvidersNotice.vue new file mode 100644 index 000000000..9e4da2c77 --- /dev/null +++ b/client/src/features/settings/metadata-preferences/components/MovedProvidersNotice.vue @@ -0,0 +1,49 @@ + + + diff --git a/client/src/features/settings/metadata-preferences/components/ProviderAddMenu.vue b/client/src/features/settings/metadata-preferences/components/ProviderAddMenu.vue index b915c3d91..2d005cfc8 100644 --- a/client/src/features/settings/metadata-preferences/components/ProviderAddMenu.vue +++ b/client/src/features/settings/metadata-preferences/components/ProviderAddMenu.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n' import { Plus } from '@lucide/vue' import type { MetadataField, MetadataProviderKey, ProviderStatus } from '@bookorbit/types' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' +import { providerChipStyle, providerShortLabel } from '@/lib/provider-colors' import { providerHasOtherShape, skipReasonFor } from '../lib/field-rules' const { t } = useI18n() @@ -24,18 +24,19 @@ const emit = defineEmits<{ add: [provider: MetadataProviderKey] }>() const open = ref(false) // Usable providers first: an unconfigured one, or one that never fills this field, can still be -// added, but it is not the choice anyone is looking for. +// added, but it is not the choice anyone is looking for. A source that moved out and is not +// registered again is listed only so rules that name it still read; it is not offered. const candidates = computed(() => { - const available = props.statuses.filter((status) => !props.assigned.includes(status.key as MetadataProviderKey)) + const available = props.statuses.filter((status) => !status.movedOut && !props.assigned.includes(status.key as MetadataProviderKey)) return available .map((status) => { const skipReason = skipReasonFor(status, props.field) return { key: status.key as MetadataProviderKey, label: status.label, - short: PROVIDER_SHORT_LABELS[status.key] ?? status.key, + short: providerShortLabel(status.key, status.label), skipReason, - otherShape: !skipReason && providerHasOtherShape(status.key as MetadataProviderKey, props.field), + otherShape: !skipReason && providerHasOtherShape(status.key as MetadataProviderKey, props.field, status.supportedFields), } }) .sort((a, b) => Number(Boolean(a.skipReason || a.otherShape)) - Number(Boolean(b.skipReason || b.otherShape))) diff --git a/client/src/features/settings/metadata-preferences/components/ProviderBrandTile.vue b/client/src/features/settings/metadata-preferences/components/ProviderBrandTile.vue index 6d60dc300..29a6a0d10 100644 --- a/client/src/features/settings/metadata-preferences/components/ProviderBrandTile.vue +++ b/client/src/features/settings/metadata-preferences/components/ProviderBrandTile.vue @@ -7,6 +7,8 @@ const props = defineProps<{ providerKey: string label: string enabled: boolean + /** For a provider not registered yet, whose colour BookOrbit does not know. */ + color?: string | null }>() const iconPath = computed(() => providerIconPathSafe(props.providerKey)) @@ -26,7 +28,7 @@ const monogram = computed(() => {