feat(authors): add Goodreads as an author metadata source

Audnexus was the only author provider, and it returns just an ASIN, name,
description and image. Goodreads carries a far richer author record, so add it
as a second provider and let each field choose where it comes from.

The Goodreads API was retired in 2020, so the provider scrapes: a name resolves
to an author id through the WAF-free /book/auto_complete endpoint, falling back
to the author search page for names that only differ by a middle initial, and
the author page yields the biography, photo, birth and death dates, website,
genres and influences. Two rendering quirks are worth knowing about, since both
silently return the wrong value: free-text fields appear twice, truncated and
whole, so the parser takes whichever copy carries the most content; and the
birthplace sits in a bare text node while only the date is wrapped in a
dataItem, so authors with a place but no date correctly store nothing.

Preferences are now per field, mirroring the book-side FieldPreference model,
with an ordered provider list and a merge strategy each. They are global,
because an author row is shared across every library and cannot hold a
different biography per library. Providers declare which fields they can return
and both the resolver and the settings UI filter on that, so Audnexus can never
be assigned to a field it has no way to fill. The single global write mode is
migrated into each field's merge strategy on first read, keeping an existing
install's overwrite behaviour instead of silently adopting the default.

The executor now queries every listed provider and resolves each field
independently, first non-empty value winning, and records which provider
supplied the record so a re-run can pin the match instead of searching by name
again. overwriteIfProvided skips empty values, which is what lets a matched
author with an empty Audnexus biography fall through to Goodreads.
This commit is contained in:
neon
2026-08-18 03:43:22 +00:00
parent 75c6c69a10
commit 42f175f1ef
58 changed files with 18411 additions and 417 deletions
@@ -11,6 +11,15 @@ const author: AuthorDetail = {
imageUrl: null,
bookCount: 24,
lastAddedAt: '2026-01-01T00:00:00.000Z',
birthDate: null,
birthYear: null,
deathDate: null,
deathYear: null,
website: null,
genres: [],
influences: [],
metadataProvider: null,
metadataProviderId: null,
}
describe('AuthorHeader', () => {
@@ -22,3 +31,24 @@ describe('AuthorHeader', () => {
expect(wrapper.get('section').classes()).toContain('shrink-0')
})
})
describe('life dates', () => {
it('renders the stored day regardless of a timezone behind UTC', () => {
// yyyy-MM-dd parses as UTC midnight; formatting it in America/Denver
// without pinning the zone renders the previous day.
const wrapper = mount(AuthorHeader, {
props: { author: { ...author, birthDate: '1898-11-29', deathDate: '1963-11-22' } },
})
expect(wrapper.text()).toContain('Nov 29, 1898')
expect(wrapper.text()).toContain('Nov 22, 1963')
})
it('falls back to the year when only a year is known', () => {
const wrapper = mount(AuthorHeader, {
props: { author: { ...author, birthDate: null, birthYear: 1929 } },
})
expect(wrapper.text()).toContain('1929')
})
})
@@ -35,6 +35,28 @@ const initials = computed(() => {
return `${parts[0]!.charAt(0)}${parts[1]!.charAt(0)}`.toUpperCase()
})
// Goodreads supplies a full date for most authors and only a year for older
// ones, so fall back to the year rather than showing nothing.
function lifeDateLabel(date: string | null, year: number | null): string | null {
if (date) {
const parsed = new Date(date)
// Birth and death are plain yyyy-MM-dd, which Date parses as UTC midnight.
// Formatting that in a timezone behind UTC would render the previous day,
// so read it back in UTC too.
if (!Number.isNaN(parsed.getTime())) {
return formatDate(parsed, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })
}
}
// A year is not a quantity: grouping would render 1929 as "1,929".
return year !== null ? formatNumber(year, { useGrouping: false }) : null
}
const bornLabel = computed(() => lifeDateLabel(props.author.birthDate, props.author.birthYear))
const diedLabel = computed(() => lifeDateLabel(props.author.deathDate, props.author.deathYear))
const genres = computed(() => props.author.genres ?? [])
const influences = computed(() => props.author.influences ?? [])
const hasFacts = computed(() => !!(bornLabel.value || diedLabel.value || props.author.website || genres.value.length))
const resolvedBio = computed(() => {
const local = props.author.description?.trim()
if (local) return local
@@ -158,6 +180,33 @@ watch(resolvedBio, () => {
</p>
</div>
<dl v-if="hasFacts" class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div v-if="bornLabel" class="flex gap-1.5">
<dt class="text-muted-foreground">{{ t('author.header.born') }}</dt>
<dd class="text-foreground">{{ bornLabel }}</dd>
</div>
<div v-if="diedLabel" class="flex gap-1.5">
<dt class="text-muted-foreground">{{ t('author.header.died') }}</dt>
<dd class="text-foreground">{{ diedLabel }}</dd>
</div>
<div v-if="author.website" class="flex gap-1.5 min-w-0">
<dt class="text-muted-foreground">{{ t('author.header.website') }}</dt>
<dd class="min-w-0 truncate">
<a :href="author.website" target="_blank" rel="noopener noreferrer" class="text-foreground underline underline-offset-2">
{{ author.website }}
</a>
</dd>
</div>
<div v-if="genres.length" class="flex gap-1.5 min-w-0">
<dt class="text-muted-foreground">{{ t('author.header.genres') }}</dt>
<dd class="min-w-0 text-foreground">{{ genres.join(', ') }}</dd>
</div>
<div v-if="influences.length" class="flex gap-1.5 min-w-0">
<dt class="text-muted-foreground">{{ t('author.header.influences') }}</dt>
<dd class="min-w-0 text-foreground">{{ influences.join(', ') }}</dd>
</div>
</dl>
<div class="mt-4 flex gap-3">
<div class="rounded-lg border border-border/70 bg-background/40 px-4 py-2.5">
<p class="text-base font-semibold text-foreground">{{ formatNumber(author.bookCount) }}</p>
@@ -157,6 +157,15 @@ function makeAuthor(overrides: Partial<AuthorDetail> = {}): AuthorDetail {
imageUrl: null,
bookCount: 2,
lastAddedAt: '2026-01-01T00:00:00.000Z',
birthDate: null,
birthYear: null,
deathDate: null,
deathYear: null,
website: null,
genres: [],
influences: [],
metadataProvider: null,
metadataProviderId: null,
...overrides,
}
}
@@ -8,13 +8,13 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import type { AuthorAutoEnrichmentConfig } from '@bookorbit/types'
import { api } from '@/lib/api'
import { useAuthorEligibleCountPreview } from './composables/useAuthorEligibleCountPreview'
import AuthorMetadataPreferences from './components/AuthorMetadataPreferences.vue'
const { t } = useI18n()
const DEFAULT_CONFIG: AuthorAutoEnrichmentConfig = {
enabled: false,
triggerOnImport: true,
writeMode: 'missing_only',
conditions: { neverEnriched: true, missingBio: false, missingPhoto: false },
}
@@ -23,6 +23,7 @@ const config = ref<AuthorAutoEnrichmentConfig>({
conditions: { ...DEFAULT_CONFIG.conditions },
})
const saving = ref(false)
const metadataPreferencesRef = ref<InstanceType<typeof AuthorMetadataPreferences> | null>(null)
const authorBackfillRunning = ref(false)
const authorBackfillAllRunning = ref(false)
@@ -40,6 +41,7 @@ async function saveConfig() {
if (saving.value) return
saving.value = true
try {
await metadataPreferencesRef.value?.save()
const res = await api('/api/v1/authors/enrichment/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -79,11 +81,6 @@ function toggleCondition(key: keyof AuthorAutoEnrichmentConfig['conditions']) {
}
}
function onWriteModeChange(event: Event) {
const mode = (event.target as HTMLSelectElement).value as AuthorAutoEnrichmentConfig['writeMode']
config.value = { ...config.value, writeMode: mode }
}
async function runAuthorBackfill() {
if (authorBackfillRunning.value) return
authorBackfillRunning.value = true
@@ -154,24 +151,7 @@ async function runAuthorBackfillAll() {
<ToggleSwitch class="self-start" :model-value="config.enabled" :disabled="saving" @update:model-value="toggleEnabled" />
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between md:gap-6 bg-card">
<div>
<p class="settings-label">
{{ t('settings.admin.authorEnrichment.updateStrategyTitle') }}
</p>
<p class="settings-hint">
{{ t('settings.admin.authorEnrichment.updateStrategyHint') }}
</p>
</div>
<select class="select-field w-full md:w-64" :value="config.writeMode" :disabled="saving" @change="onWriteModeChange">
<option value="missing_only">
{{ t('settings.admin.authorEnrichment.writeModeMissingOnly') }}
</option>
<option value="always_refetch">
{{ t('settings.admin.authorEnrichment.writeModeAlwaysRefetch') }}
</option>
</select>
</div>
<AuthorMetadataPreferences ref="metadataPreferencesRef" />
<template v-if="config.enabled">
<div class="px-4 py-3.5 md:px-5 md:py-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between md:gap-6 bg-card">
@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { ALL_AUTHOR_METADATA_FIELDS } from '@bookorbit/types'
import AuthorMetadataPreferences from '../components/AuthorMetadataPreferences.vue'
import ProviderChipList from '../metadata-preferences/components/ProviderChipList.vue'
const apiMock = vi.hoisted(() => vi.fn<(input: string, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
const PROVIDERS = [
{ key: 'goodreads', label: 'Goodreads', identifiable: true, supportedFields: [...ALL_AUTHOR_METADATA_FIELDS] },
{ key: 'audnexus', label: 'Audnexus', identifiable: true, supportedFields: ['description', 'photo'] },
]
function preferences() {
return {
fields: Object.fromEntries(
ALL_AUTHOR_METADATA_FIELDS.map((field) => [
field,
{
enabled: true,
providers: field === 'description' ? ['goodreads', 'audnexus'] : ['goodreads'],
mergeStrategy: 'fillMissing',
},
]),
),
}
}
function mockLoad(prefs: unknown = preferences()) {
apiMock.mockImplementation((input: string) => {
if (input.includes('/metadata/providers')) {
return Promise.resolve({ ok: true, json: async () => PROVIDERS })
}
return Promise.resolve({ ok: true, json: async () => prefs })
})
}
describe('AuthorMetadataPreferences', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders a row for every author metadata field', async () => {
mockLoad()
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
// test-setup installs the real English catalog, so these are the shipped labels.
for (const label of ['Biography', 'Photo', 'Born', 'Died', 'Website', 'Genres', 'Influences']) {
expect(wrapper.text()).toContain(label)
}
expect(wrapper.findAll('[role="switch"]')).toHaveLength(ALL_AUTHOR_METADATA_FIELDS.length)
})
it('renders nothing until preferences have loaded', () => {
apiMock.mockReturnValue(new Promise(() => undefined))
const wrapper = mount(AuthorMetadataPreferences)
expect(wrapper.text()).toBe('')
})
it('exposes each field enable state through an accessible switch', async () => {
mockLoad()
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
const switches = wrapper.findAll('[role="switch"]')
expect(switches).toHaveLength(ALL_AUTHOR_METADATA_FIELDS.length)
expect(switches[0]!.attributes('aria-checked')).toBe('true')
})
it('toggles a field off without touching the others', async () => {
mockLoad()
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
await wrapper.findAll('[role="switch"]')[0]!.trigger('click')
const switches = wrapper.findAll('[role="switch"]')
expect(switches[0]!.attributes('aria-checked')).toBe('false')
expect(switches[1]!.attributes('aria-checked')).toBe('true')
})
it('saves the current preferences to the author endpoint', async () => {
mockLoad()
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
await (wrapper.vm as unknown as { save: () => Promise<void> }).save()
const put = apiMock.mock.calls.find(([, init]) => init?.method === 'PUT')
expect(put?.[0]).toBe('/api/v1/authors/metadata/preferences')
expect(JSON.parse(String(put?.[1]?.body)).fields.description.providers).toEqual(['goodreads', 'audnexus'])
})
it('only offers drag-to-reorder where two providers can fill the field', async () => {
mockLoad()
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
// Biography and Photo take both providers; the other five can only come
// from Goodreads, so they render as plain text with nothing to reorder.
expect(wrapper.findAllComponents(ProviderChipList)).toHaveLength(2)
expect(wrapper.text()).toContain('Audnexus supplies only biographies and photos.')
})
it('rejects an unsupported provider dropped onto a field', async () => {
mockLoad()
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
// Only Biography and Photo render a chip list, and both accept Audnexus.
// Feed the Biography row a provider it cannot use to prove the guard holds.
const biographyRow = wrapper.findAllComponents(ProviderChipList)[0]!
biographyRow.vm.$emit('update:providers', ['goodreads', 'audnexus'])
await flushPromises()
await (wrapper.vm as unknown as { save: () => Promise<void> }).save()
const put = apiMock.mock.calls.find(([, init]) => init?.method === 'PUT')
expect(JSON.parse(String(put?.[1]?.body)).fields.description.providers).toEqual(['goodreads', 'audnexus'])
})
it('offers a way to add back a capable provider that was removed', async () => {
// ProviderChipList can only reorder and remove, so without this the second
// provider is unrecoverable once it leaves the list.
const reduced = preferences()
reduced.fields.description!.providers = ['goodreads']
mockLoad(reduced)
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
const add = wrapper.get('button[aria-label="Add Audnexus as a source"]')
await add.trigger('click')
await (wrapper.vm as unknown as { save: () => Promise<void> }).save()
const put = apiMock.mock.calls.find(([, init]) => init?.method === 'PUT')
expect(JSON.parse(String(put?.[1]?.body)).fields.description.providers).toEqual(['goodreads', 'audnexus'])
})
it('shows no add button once every capable provider is assigned', async () => {
const full = preferences()
full.fields.description!.providers = ['goodreads', 'audnexus']
full.fields.photo!.providers = ['goodreads', 'audnexus']
mockLoad(full)
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
expect(wrapper.find('button[aria-label="Add Audnexus as a source"]').exists()).toBe(false)
})
it('names the only possible source on a single-provider field', async () => {
mockLoad()
const wrapper = mount(AuthorMetadataPreferences)
await flushPromises()
// Website can only come from Goodreads, so the row states it rather than
// offering a chip that can never be reordered.
expect(wrapper.text()).toContain('Website')
expect(wrapper.text()).toContain('Goodreads')
})
})
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { ALL_AUTHOR_METADATA_FIELDS, providerSupportsAuthorField } from '@bookorbit/types'
import type {
AuthorFieldPreference,
AuthorMetadataField,
AuthorMetadataPreferences,
AuthorMetadataProviderInfo,
AuthorMetadataProviderKey,
MetadataProviderKey,
ProviderStatus,
} from '@bookorbit/types'
import { Plus } from '@lucide/vue'
import { api } from '@/lib/api'
import ProviderChipList from '../metadata-preferences/components/ProviderChipList.vue'
import MergeStrategyPicker from '../metadata-preferences/components/MergeStrategyPicker.vue'
const { t } = useI18n()
const preferences = ref<AuthorMetadataPreferences | null>(null)
const providers = ref<AuthorMetadataProviderInfo[]>([])
const saving = ref(false)
const fields = computed(() => ALL_AUTHOR_METADATA_FIELDS)
// ProviderChipList is shared with book metadata and describes providers as
// ProviderStatus. Author provider keys are the same string values, so it takes
// them unchanged.
const statuses = computed<ProviderStatus[]>(() =>
providers.value.map((provider) => ({
key: provider.key as unknown as MetadataProviderKey,
label: provider.label,
configured: true,
enabled: true,
})),
)
function capableProviders(field: AuthorMetadataField): AuthorMetadataProviderInfo[] {
return providers.value.filter((provider) => providerSupportsAuthorField(provider.key, field))
}
// Ordering only means something where more than one provider can fill the
// field. Audnexus returns just a biography and a photo, so the other five
// fields have a single possible source and render as plain text.
function isOrderable(field: AuthorMetadataField): boolean {
return capableProviders(field).length > 1
}
// ProviderChipList can reorder and remove but never add, so an orderable field
// needs its own affordance or a removed provider is gone for good.
function unassignedProviders(field: AuthorMetadataField): AuthorMetadataProviderInfo[] {
const assigned = new Set<string>(preferenceFor(field)?.providers ?? [])
return capableProviders(field).filter((provider) => !assigned.has(provider.key))
}
function addProvider(field: AuthorMetadataField, key: AuthorMetadataProviderKey) {
const current = preferenceFor(field)
if (!current || current.providers.includes(key)) return
update(field, { providers: [...current.providers, key] })
}
function soleProviderLabel(field: AuthorMetadataField): string {
return capableProviders(field)[0]?.label ?? t('settings.admin.authorEnrichment.metadataPreferences.noProviders')
}
const providersHint = computed(() => {
const limited = providers.value.filter((provider) => provider.supportedFields.length < ALL_AUTHOR_METADATA_FIELDS.length)
if (limited.length === 0) return ''
return t('settings.admin.authorEnrichment.metadataPreferences.limitedProviders', {
providers: limited.map((provider) => provider.label).join(', '),
})
})
onMounted(async () => {
const [prefsRes, providersRes] = await Promise.all([api('/api/v1/authors/metadata/preferences'), api('/api/v1/authors/metadata/providers')])
if (providersRes.ok) providers.value = await providersRes.json()
if (prefsRes.ok) preferences.value = await prefsRes.json()
})
function fieldLabel(field: AuthorMetadataField) {
return t(`settings.admin.authorEnrichment.fields.${field}`)
}
function preferenceFor(field: AuthorMetadataField): AuthorFieldPreference | null {
return preferences.value?.fields[field] ?? null
}
function providersFor(field: AuthorMetadataField): MetadataProviderKey[] {
return (preferenceFor(field)?.providers ?? []) as unknown as MetadataProviderKey[]
}
function update(field: AuthorMetadataField, patch: Partial<AuthorFieldPreference>) {
const current = preferences.value
if (!current) return
preferences.value = {
fields: { ...current.fields, [field]: { ...current.fields[field], ...patch } },
}
}
function toggleField(field: AuthorMetadataField) {
const current = preferenceFor(field)
if (!current) return
update(field, { enabled: !current.enabled })
}
function onMergeStrategyChange(field: AuthorMetadataField, mergeStrategy: AuthorFieldPreference['mergeStrategy']) {
update(field, { mergeStrategy })
}
function onProvidersChange(field: AuthorMetadataField, next: MetadataProviderKey[]) {
// The shared chip list is typed for book providers, so drop anything that is
// not a registered author provider or cannot return this field.
const filtered = (next as unknown as AuthorMetadataProviderKey[]).filter((key) => providerSupportsAuthorField(key, field))
update(field, { providers: filtered })
}
async function save() {
if (saving.value || !preferences.value) return
saving.value = true
try {
const res = await api('/api/v1/authors/metadata/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(preferences.value),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
preferences.value = await res.json()
toast.success(t('settings.admin.authorEnrichment.metadataPreferences.saved'))
} catch {
toast.error(t('settings.admin.authorEnrichment.metadataPreferences.saveFailed'))
} finally {
saving.value = false
}
}
defineExpose({ save })
</script>
<template>
<div v-if="preferences" class="flex flex-col">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<p class="settings-label">
{{ t('settings.admin.authorEnrichment.metadataPreferences.title') }}
</p>
<p class="settings-hint">
{{ t('settings.admin.authorEnrichment.metadataPreferences.hint') }}
<span v-if="providersHint">{{ providersHint }}</span>
</p>
</div>
<div
v-for="field in fields"
:key="field"
class="px-4 py-2.5 md:px-5 md:py-3 flex flex-col gap-2 md:flex-row md:items-center md:gap-4 bg-card border-t border-border"
>
<div class="flex items-center gap-3 md:w-48 shrink-0">
<button
type="button"
role="switch"
:aria-checked="preferences.fields[field].enabled"
:aria-label="fieldLabel(field)"
:disabled="saving"
class="relative flex h-4.5 w-8.5 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="preferences.fields[field].enabled ? 'bg-primary' : 'bg-muted border border-border'"
@click="toggleField(field)"
>
<span
class="inline-block h-3.5 w-3.5 rounded-full bg-white shadow-xs transition-transform"
:class="preferences.fields[field].enabled ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
<span class="text-sm text-foreground">{{ fieldLabel(field) }}</span>
</div>
<div class="flex-1 min-w-0">
<div v-if="isOrderable(field)" class="flex flex-wrap items-center gap-2">
<ProviderChipList
:providers="providersFor(field)"
:statuses="statuses"
:disabled="saving || !preferences.fields[field].enabled"
@update:providers="(next: MetadataProviderKey[]) => onProvidersChange(field, next)"
/>
<button
v-for="provider in unassignedProviders(field)"
:key="provider.key"
type="button"
:disabled="saving || !preferences.fields[field].enabled"
:aria-label="t('settings.admin.authorEnrichment.metadataPreferences.addProvider', { provider: provider.label })"
class="flex items-center gap-1 h-6 px-2 rounded border border-dashed border-border text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50"
@click="addProvider(field, provider.key)"
>
<Plus :size="11" :stroke-width="3" />
{{ provider.label }}
</button>
</div>
<span v-else class="text-sm" :class="preferences.fields[field].enabled ? 'text-foreground' : 'text-muted-foreground'">
{{ soleProviderLabel(field) }}
</span>
</div>
<div class="md:w-48 shrink-0">
<MergeStrategyPicker
:model-value="preferences.fields[field].mergeStrategy"
:disabled="saving || !preferences.fields[field].enabled"
@update:model-value="(value: AuthorFieldPreference['mergeStrategy']) => onMergeStrategyChange(field, value)"
/>
</div>
</div>
</div>
</template>
-4
View File
@@ -1079,10 +1079,6 @@
"runAllShort": "Spustit vše",
"enableTitle": "Povolit obohacování autora",
"enableHint": "Automaticky načítat biografie autora a profilové fotografie při přidávání nebo aktualizaci knih.",
"updateStrategyTitle": "Aktualizovat strategii",
"updateStrategyHint": "Co dělat, když autor již má biografii nebo fotografii.",
"writeModeMissingOnly": "Vyplňte pouze chybějící údaje (doporučeno)",
"writeModeAlwaysRefetch": "Vždy aktualizovat existující data",
"triggerOnImportTitle": "Spouštěč při importu",
"triggerOnImportHint": "Autoři fronty pro obohacení při prvním přidávání knih do knihovny.",
"eligibilityTitle": "Podmínky způsobilosti",
-4
View File
@@ -1085,10 +1085,6 @@
"runAllShort": "Kør alle",
"enableTitle": "Aktiver automatisk forfatterberigelse",
"enableHint": "Hent automatisk forfatter biografier og profilbilleder, når bøger tilføjes eller opdateres.",
"updateStrategyTitle": "Opdater strategi",
"updateStrategyHint": "Hvad man skal gøre, når en forfatter allerede har en biografi eller foto.",
"writeModeMissingOnly": "Udfyld kun manglende data (anbefalet)",
"writeModeAlwaysRefetch": "Opdater altid eksisterende data",
"triggerOnImportTitle": "Udløser ved import",
"triggerOnImportHint": "Kø forfattere til berigelse når bøger først tilføjes til et bibliotek.",
"eligibilityTitle": "Betingelser for støtteberettigelse",
-4
View File
@@ -1027,10 +1027,6 @@
"runAllShort": "Alle ausführen",
"enableTitle": "Automatische Autorenanreicherung aktivieren",
"enableHint": "Ruft automatisch Autorenbiografien und Profilfotos ab, wenn Bücher hinzugefügt oder aktualisiert werden.",
"updateStrategyTitle": "Aktualisierungsstrategie",
"updateStrategyHint": "Was zu tun ist, wenn ein Autor bereits eine Biografie oder ein Foto hat.",
"writeModeMissingOnly": "Nur fehlende Daten ergänzen (empfohlen)",
"writeModeAlwaysRefetch": "Vorhandene Daten immer aktualisieren",
"triggerOnImportTitle": "Beim Import auslösen",
"triggerOnImportHint": "Autoren zur Anreicherung einreihen, wenn Bücher erstmals einer Bibliothek hinzugefügt werden.",
"eligibilityTitle": "Berechtigungsbedingungen",
+25 -6
View File
@@ -1122,10 +1122,6 @@
"runAllShort": "Run all",
"enableTitle": "Enable auto author enrichment",
"enableHint": "Automatically fetch author biographies and profile photos when books are added or updated.",
"updateStrategyTitle": "Update strategy",
"updateStrategyHint": "What to do when an author already has a biography or photo.",
"writeModeMissingOnly": "Only fill missing data (recommended)",
"writeModeAlwaysRefetch": "Always refresh existing data",
"triggerOnImportTitle": "Trigger on import",
"triggerOnImportHint": "Queue authors for enrichment when books are first added to a library.",
"eligibilityTitle": "Eligibility conditions",
@@ -1143,7 +1139,25 @@
"saveFailed": "Failed to save author enrichment settings",
"noEligibleAuthors": "No eligible authors found",
"noAuthorsToEnrich": "No authors to enrich",
"queueFailed": "Failed to queue author backfill"
"queueFailed": "Failed to queue author backfill",
"fields": {
"description": "Biography",
"photo": "Photo",
"birthDate": "Born",
"deathDate": "Died",
"website": "Website",
"genres": "Genres",
"influences": "Influences"
},
"metadataPreferences": {
"title": "Field sources",
"hint": "Set the order each field is filled from. The first provider with a value wins.",
"noProviders": "No provider available",
"saved": "Field sources saved",
"saveFailed": "Could not save field sources",
"limitedProviders": "{providers} supplies only biographies and photos.",
"addProvider": "Add {provider} as a source"
}
},
"scoreWeights": {
"groupLabel": "Score Weights",
@@ -3257,7 +3271,12 @@
"noBiography": "No biography available. Use the menu to refresh metadata.",
"previewFrom": "Preview from {provider}. Save metadata to persist it.",
"booksLabel": "Books",
"lastAdded": "Last Added"
"lastAdded": "Last Added",
"born": "Born",
"died": "Died",
"website": "Website",
"genres": "Genres",
"influences": "Influences"
},
"list": {
"title": "Authors",
-4
View File
@@ -1122,10 +1122,6 @@
"runAllShort": "ejecutar todo",
"enableTitle": "Habilitar el enriquecimiento automático de autores",
"enableHint": "Obtenga automáticamente biografías de autores y fotografías de perfil cuando se agreguen o actualicen libros.",
"updateStrategyTitle": "Estrategia de actualización",
"updateStrategyHint": "Qué hacer cuando un autor ya tiene una biografía o una foto.",
"writeModeMissingOnly": "Complete solo los datos faltantes (recomendado)",
"writeModeAlwaysRefetch": "Actualizar siempre los datos existentes",
"triggerOnImportTitle": "Activar al importar",
"triggerOnImportHint": "Ponga en cola a los autores para enriquecerlos cuando los libros se agregan por primera vez a una biblioteca.",
"eligibilityTitle": "Condiciones de elegibilidad",
-4
View File
@@ -1064,10 +1064,6 @@
"runAllShort": "Suorita kaikki",
"enableTitle": "Ota käyttöön automaattisen tekijän rikastus",
"enableHint": "Hae automaattisesti kirjailijan elämäkerrat ja profiilikuvat kun kirjoja lisätään tai päivitetään.",
"updateStrategyTitle": "Päivitä strategia",
"updateStrategyHint": "Mitä tehdä, kun kirjailija on jo elämäkerta tai kuva.",
"writeModeMissingOnly": "Täytä vain puuttuvat tiedot (suositeltu)",
"writeModeAlwaysRefetch": "Päivitä olemassa olevat tiedot aina",
"triggerOnImportTitle": "Käynnistä tuonnin yhteydessä",
"triggerOnImportHint": "Jonon kirjoittajat rikastamista varten, kun kirjat lisätään kirjastoon.",
"eligibilityTitle": "Tukikelpoisuusedellytykset",
-4
View File
@@ -1106,10 +1106,6 @@
"runAllShort": "Exécutez tout",
"enableTitle": "Activer l'enrichissement automatique des auteurs",
"enableHint": "Récupérez automatiquement les biographies des auteurs et les photos de profil lorsque des livres sont ajoutés ou mis à jour.",
"updateStrategyTitle": "Stratégie de mise à jour",
"updateStrategyHint": "Que faire lorsqu'un auteur possède déjà une biographie ou une photo.",
"writeModeMissingOnly": "Ne remplir que les données manquantes (recommandé)",
"writeModeAlwaysRefetch": "Actualisez toujours les données existantes",
"triggerOnImportTitle": "Déclencheur à l'importation",
"triggerOnImportHint": "Mettez les auteurs en file d'attente pour les enrichir lorsque les livres sont ajoutés pour la première fois à une bibliothèque.",
"eligibilityTitle": "Conditions d'éligibilité",
-4
View File
@@ -1094,10 +1094,6 @@
"runAllShort": "Esegui tutto",
"enableTitle": "Abilita l'arricchimento automatico dell'autore",
"enableHint": "Recupera automaticamente le biografie degli autori e le foto del profilo quando i libri vengono aggiunti o aggiornati.",
"updateStrategyTitle": "Aggiorna la strategia",
"updateStrategyHint": "Cosa fare quando un autore ha già una biografia o una foto.",
"writeModeMissingOnly": "Compila solo i dati mancanti (consigliato)",
"writeModeAlwaysRefetch": "Aggiorna sempre i dati esistenti",
"triggerOnImportTitle": "Attiva all'importazione",
"triggerOnImportHint": "Metti in coda gli autori per l'arricchimento quando i libri vengono aggiunti per la prima volta a una libreria.",
"eligibilityTitle": "Condizioni di ammissibilità",
-4
View File
@@ -1006,10 +1006,6 @@
"runAllShort": "Alles uitvoeren",
"enableTitle": "Automatische auteursverrijking inschakelen",
"enableHint": "Haal automatisch auteursbiografieën en profielfoto's op wanneer boeken worden toegevoegd of bijgewerkt.",
"updateStrategyTitle": "Bijwerkstrategie",
"updateStrategyHint": "Wat te doen wanneer een auteur al een biografie of foto heeft.",
"writeModeMissingOnly": "Alleen ontbrekende gegevens invullen (aanbevolen)",
"writeModeAlwaysRefetch": "Bestaande gegevens altijd vernieuwen",
"triggerOnImportTitle": "Activeren bij import",
"triggerOnImportHint": "Zet auteurs in de wachtrij voor verrijking wanneer boeken voor het eerst aan een bibliotheek worden toegevoegd.",
"eligibilityTitle": "Geschiktheidsvoorwaarden",
-4
View File
@@ -1090,10 +1090,6 @@
"runAllShort": "Uruchom wszystko",
"enableTitle": "Włącz automatyczne wzbogacanie autora",
"enableHint": "Automatycznie pobieraj biografie autorów i zdjęcia profilowe po dodaniu lub aktualizacji książek.",
"updateStrategyTitle": "Aktualizuj strategię",
"updateStrategyHint": "Co zrobić, gdy autor ma już biografię lub zdjęcie.",
"writeModeMissingOnly": "Uzupełnij tylko brakujące dane (zalecane)",
"writeModeAlwaysRefetch": "Zawsze odświeżaj istniejące dane",
"triggerOnImportTitle": "Wyzwalaj przy imporcie",
"triggerOnImportHint": "Kolejkuj autorów w celu wzbogacenia, gdy książki są po raz pierwszy dodawane do biblioteki.",
"eligibilityTitle": "Warunki kwalifikowalności",
-4
View File
@@ -1113,10 +1113,6 @@
"runAllShort": "Executar todos",
"enableTitle": "Ativar enriquecimento automático de autor",
"enableHint": "Buscar automaticamente biografias e fotos de perfil de autores quando livros forem adicionados ou atualizados.",
"updateStrategyTitle": "Estratégia de atualização",
"updateStrategyHint": "O que fazer quando um autor já possui uma biografia ou foto.",
"writeModeMissingOnly": "Preencher apenas dados ausentes (recomendado)",
"writeModeAlwaysRefetch": "Sempre atualizar os dados existentes",
"triggerOnImportTitle": "Disparar na importação",
"triggerOnImportHint": "Colocar autores na fila para enriquecimento quando os livros forem adicionados pela primeira vez a uma biblioteca.",
"eligibilityTitle": "Condições de elegibilidade",
-4
View File
@@ -1110,10 +1110,6 @@
"runAllShort": "Запустить все",
"enableTitle": "Включить автоматическое обогащение автора",
"enableHint": "Автоматически получать биографии автора и фотографии профиля при добавлении или обновлении книг.",
"updateStrategyTitle": "Обновить стратегию",
"updateStrategyHint": "Что делать, когда у автора уже есть биография или фотография.",
"writeModeMissingOnly": "Заполнять только недостающие данные (рекомендуется)",
"writeModeAlwaysRefetch": "Всегда обновлять существующие данные",
"triggerOnImportTitle": "Триггер при импорте",
"triggerOnImportHint": "Очередь авторов для обогащения при первом добавлении книг в библиотеку.",
"eligibilityTitle": "Условия предоставления права",
-4
View File
@@ -1106,10 +1106,6 @@
"runAllShort": "Zaženi vse",
"enableTitle": "Omogoči samodejno obogatitev avtorjev",
"enableHint": "Samodejno pridobi biografije avtorjev in profilne fotografije, ko so knjige dodane ali posodobljene.",
"updateStrategyTitle": "Strategija posodabljanja",
"updateStrategyHint": "Kaj storiti, ko ima avtor že biografijo ali fotografijo.",
"writeModeMissingOnly": "Izpolni samo manjkajoče podatke (priporočeno)",
"writeModeAlwaysRefetch": "Vedno osveži obstoječe podatke",
"triggerOnImportTitle": "Sproži ob uvozu",
"triggerOnImportHint": "Uvrsti avtorje v vrsto za obogatitev, ko so knjige prvič dodane v knjižnico.",
"eligibilityTitle": "Pogoji upravičenosti",
-4
View File
@@ -1084,10 +1084,6 @@
"runAllShort": "Kör alla",
"enableTitle": "Aktivera automatisk författarberikning",
"enableHint": "Hämta automatiskt författarbiografier och profilbilder när böcker läggs till eller uppdateras.",
"updateStrategyTitle": "Uppdatera strategi",
"updateStrategyHint": "Att göra när en författare redan har en biografi eller foto.",
"writeModeMissingOnly": "Fyll bara i saknade data (rekommenderas)",
"writeModeAlwaysRefetch": "Uppdatera alltid befintliga data",
"triggerOnImportTitle": "Utlösare vid import",
"triggerOnImportHint": "Köförfattare för berikning när böcker först läggs till ett bibliotek.",
"eligibilityTitle": "Behörighetsvillkor",
-4
View File
@@ -1067,10 +1067,6 @@
"runAllShort": "Запустити всі",
"enableTitle": "Увімкнути автоматичне збагачення автора",
"enableHint": "Автоматично отримувати авторські біографії та фотографії профілю, коли книги додаються або оновлюються.",
"updateStrategyTitle": "Оновити стратегію",
"updateStrategyHint": "Що робити, коли у автора вже є біографія або фото.",
"writeModeMissingOnly": "Заповнювати лише відсутні дані (рекомендовано)",
"writeModeAlwaysRefetch": "Завжди оновлювати існуючі дані",
"triggerOnImportTitle": "Тригер при імпорті",
"triggerOnImportHint": "Автори черги на збагачення, коли книги спочатку додаються в бібліотеку.",
"eligibilityTitle": "Умови гідності",
-4
View File
@@ -1110,10 +1110,6 @@
"runAllShort": "运行所有",
"enableTitle": "启用作者信息自动补充",
"enableHint": "添加或更新书籍时,自动获取作者简介和个人头像。",
"updateStrategyTitle": "更新策略",
"updateStrategyHint": "当作者已经有了传记或照片时怎么办。",
"writeModeMissingOnly": "仅补充缺失数据(推荐)",
"writeModeAlwaysRefetch": "总是刷新现有数据",
"triggerOnImportTitle": "导入时触发",
"triggerOnImportHint": "当书籍首次添加到书库时,队列作者。",
"eligibilityTitle": "资格条件",