feat(book-requests): link releases to their source page and hold imports for review

Torznab and Newznab read the feed's details page, plugins may send infoUrl, and the
search drops any link that could carry a credential. A grab can ask to be held for
review whatever its score, and the held review shows the file's own edition facts,
its cover and the release page. Torznab guids are now digested like Newznab's.

Refs #1438
This commit is contained in:
neon
2026-10-20 16:42:16 +00:00
parent 3c87418416
commit a728bf293b
44 changed files with 25375 additions and 68 deletions
@@ -85,6 +85,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
indexerName: 'MyAnonaMouse',
guid: 'g1',
title: 'Spark of the Everflame',
infoUrl: null,
sizeBytes: 3_100_000,
seeders: 506,
leechers: 2,
@@ -671,6 +672,70 @@ describe('ReleasePickerPanel', () => {
expect(grabButton(rowFor('libgen-1')).attributes('disabled')).toBeUndefined()
})
describe('the release page', () => {
it("links the release title to its source's page, out of this instance's referrer", async () => {
const wrapper = await render({ ...MIXED, releases: [release({ infoUrl: 'https://tracker.example.com/t/42' })], indexers: [status()] })
const link = releaseRows(wrapper)[0]!.get('a[href="https://tracker.example.com/t/42"]')
expect(link.attributes('target')).toBe('_blank')
expect(link.attributes('rel')).toBe('noopener noreferrer')
// The visible title leads the accessible name, which also says where the link goes.
expect(link.attributes('aria-label')).toMatch(/^.+, release page on MyAnonaMouse$/)
})
it('leaves the title as plain text when the source published no page', async () => {
const wrapper = await render({ ...MIXED, releases: [release({ infoUrl: null })], indexers: [status()] })
expect(releaseRows(wrapper)[0]!.find('a').exists()).toBe(false)
})
})
describe('reviewing before filing', () => {
function grabBodies(): Array<Record<string, unknown>> {
return api.mock.calls.filter(([input]) => input.includes('/grab')).map(([, init]) => JSON.parse(String(init?.body)) as Record<string, unknown>)
}
async function renderGrabbable() {
api.mockImplementation((input: string) => {
if (input.includes('/releases/inspect')) return respond(READY_INSPECTION)
if (input.includes('/releases')) return respond({ ...MIXED, releases: [release()], indexers: [status()] })
if (input.includes('download-clients')) return respond([])
if (input.includes('/grab')) return respond(request({ status: 'grabbed' }))
return respond(request())
})
const wrapper = mount(ReleasePickerPanel, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
return wrapper
}
function sendButton(wrapper: VueWrapper) {
return releaseRows(wrapper)[0]!
.findAll('button')
.find((button) => /Download release|Send/.test(button.text()))!
}
it('asks the server to hold the import when the approver ticks the box', async () => {
const wrapper = await renderGrabbable()
await wrapper.get('input[type="checkbox"]').setValue(true)
await sendButton(wrapper).trigger('click')
await flushPromises()
expect(grabBodies()).toEqual([expect.objectContaining({ releaseGuid: 'g1', holdForReview: true })])
})
/** An older server rejects a field it does not know, so an unticked grab must not send it at all. */
it('sends nothing new when the box is left alone', async () => {
const wrapper = await renderGrabbable()
await sendButton(wrapper).trigger('click')
await flushPromises()
expect(grabBodies()).toHaveLength(1)
expect(grabBodies()[0]).not.toHaveProperty('holdForReview')
})
})
it('still reports an unknown count on a swarm source', async () => {
const wrapper = await render({
...MIXED,
@@ -23,13 +23,17 @@ function review(overrides: Partial<BookRequestReview> = {}): BookRequestReview {
files: [{ fileName: 'fablehaven-01.m4b', fileSize: 402_000_000, format: 'm4b', role: 'content' }],
totalSizeBytes: 402_000_000,
canFile: true,
heldOnRequest: false,
releaseInfoUrl: null,
edition: null,
hasCover: false,
...overrides,
}
}
function render(overrides: Partial<BookRequestReview> = {}, props: { canManage?: boolean; busy?: boolean } = {}) {
function render(overrides: Partial<BookRequestReview> = {}, props: { canManage?: boolean; busy?: boolean; coverUrl?: string | null } = {}) {
return mount(RequestReviewPanel, {
props: { review: review(overrides), canManage: props.canManage ?? true, busy: props.busy ?? false },
props: { review: review(overrides), canManage: props.canManage ?? true, busy: props.busy ?? false, coverUrl: props.coverUrl ?? null },
global: { stubs: { RouterLink: RouterLinkStub } },
})
}
@@ -117,4 +121,75 @@ describe('RequestReviewPanel', () => {
expect(wrapper.find('dl').exists()).toBe(false)
expect(wrapper.text()).toContain('fablehaven-01.m4b')
})
describe('a review the sender asked for', () => {
const passed = {
score: 96,
threshold: 70,
passed: true,
reason: 'above_threshold' as const,
rows: [{ field: 'title' as const, requested: 'Fablehaven', imported: 'Fablehaven', verdict: 'match' as const }],
}
it('says why it is waiting and offers to file it without calling that an override', () => {
const wrapper = render({ heldOnRequest: true, verification: passed })
expect(wrapper.text()).toContain('sent to be checked before filing')
expect(wrapper.text()).toContain('scored 96')
const file = wrapper.findAll('button').find((button) => button.text() === 'File it')
expect(file).toBeDefined()
expect(wrapper.text()).not.toContain('File it anyway')
})
it('still calls filing an override when the import also failed its check', () => {
expect(render({ heldOnRequest: true }).text()).toContain('File it anyway')
})
})
it('links the page of the release the import came from', () => {
const link = render({ releaseInfoUrl: 'https://tracker.example.com/t/42' }).get('a[href="https://tracker.example.com/t/42"]')
expect(link.text()).toContain('Open the release page')
expect(link.attributes('rel')).toBe('noopener noreferrer')
expect(link.attributes('target')).toBe('_blank')
})
it('lists only the edition facts the file states, beside the cover it carried', () => {
const wrapper = render(
{
edition: {
publisher: 'Brilliance Audio',
publishedDate: '2007',
language: 'en',
pageCount: null,
isbn10: null,
narrators: ['E.B. Stevens'],
durationSeconds: 9 * 3600 + 30 * 60,
abridged: false,
chapterCount: 12,
},
},
{ coverUrl: '/api/v1/admin/book-requests/7/review/cover?entry=100' },
)
const facts = wrapper.get('dl.grid-cols-\\[auto_minmax\\(0\\,1fr\\)\\]')
expect(facts.text()).toContain('PublisherBrilliance Audio')
expect(facts.text()).toContain('Narrated byE.B. Stevens')
expect(facts.text()).toContain('VersionUnabridged')
expect(facts.text()).toContain('Chapters12')
expect(facts.text()).not.toContain('Pages')
expect(wrapper.get('img').attributes('alt')).toBe('Cover found in the imported file')
})
it('draws no edition section for a file that states nothing and carries no cover', () => {
expect(render().text()).not.toContain('What the file says it is')
})
it('drops a cover that will not load and keeps the facts', async () => {
const wrapper = render({ edition: null }, { coverUrl: '/api/v1/admin/book-requests/7/review/cover?entry=100' })
await wrapper.get('img').trigger('error')
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).not.toContain('What the file says it is')
})
})
@@ -9,6 +9,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
indexerName: 'jackett',
guid: 'g1',
title: 'Dune',
infoUrl: null,
sizeBytes: null,
seeders: null,
leechers: null,
@@ -11,6 +11,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
indexerName: 'jackett',
guid: 'g1',
title: 'Dune',
infoUrl: null,
sizeBytes: null,
seeders: null,
leechers: null,
@@ -179,16 +179,17 @@ const {
resetSearchEditorState,
} = useReleaseSearchEditor({ request, criteria, attempts, requestId, fetchReleases, beforeSearch: startFreshSearch })
const { manualOpen, grabbing, openManual, closeManual, forgetRefusals, handleGrab, handleManualGrab, isRefused, refusalText } = useReleaseGrab({
request,
requestId,
grab: actions.grab,
setRequest,
inspectRelease,
setFilesExpanded,
deliveryFor,
afterGrab,
})
const { manualOpen, grabbing, holdForReview, openManual, closeManual, forgetRefusals, handleGrab, handleManualGrab, isRefused, refusalText } =
useReleaseGrab({
request,
requestId,
grab: actions.grab,
setRequest,
inspectRelease,
setFilesExpanded,
deliveryFor,
afterGrab,
})
/**
* Every Send is off while one release is on its way, not only the row being inspected. The
@@ -1201,19 +1202,30 @@ function showEverySource(): void {
constant on a single-indexer search, and dropping them gives the two that actually
differ the width the release title was being squeezed out of.
-->
<p class="mt-3.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ t('bookRequests.releases.summaryCount', { count: releases.length }) }}</span>
<template v-for="fact in summaryFacts" :key="fact">
<span aria-hidden="true" class="opacity-50">&middot;</span>
<span>{{ fact }}</span>
</template>
<template v-if="sharedIndexer">
<span aria-hidden="true" class="opacity-50">&middot;</span>
<span class="rounded-full border px-1.5 py-px font-medium" :class="sharedIndexerClass">
{{ t('bookRequests.releases.allIndexer', { name: sharedIndexer }) }}
</span>
</template>
</p>
<div class="mt-3.5 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ t('bookRequests.releases.summaryCount', { count: releases.length }) }}</span>
<template v-for="fact in summaryFacts" :key="fact">
<span aria-hidden="true" class="opacity-50">&middot;</span>
<span>{{ fact }}</span>
</template>
<template v-if="sharedIndexer">
<span aria-hidden="true" class="opacity-50">&middot;</span>
<span class="rounded-full border px-1.5 py-px font-medium" :class="sharedIndexerClass">
{{ t('bookRequests.releases.allIndexer', { name: sharedIndexer }) }}
</span>
</template>
</p>
<!--
Beside the list it governs rather than on each row: it answers a doubt about the
request, and it applies to a magnet pasted by hand as much as to a ranked release.
-->
<label class="flex cursor-pointer items-center gap-2 text-xs text-foreground">
<input v-model="holdForReview" type="checkbox" class="size-3.5 shrink-0 rounded-xs border-border accent-primary" />
{{ t('bookRequests.releases.holdForReview') }}
</label>
</div>
<div
v-if="nothingMatchedProfile"
@@ -1344,7 +1356,25 @@ function showEverySource(): void {
</span>
</p>
<p class="line-clamp-2 text-sm font-medium text-foreground">{{ release.title }}</p>
<!--
The title is the link because it is what the source page describes, and a
separate button would be a third control competing with Send on every row.
`noreferrer` keeps a private tracker from learning this instance's address.
-->
<p class="line-clamp-2 text-sm font-medium text-foreground">
<a
v-if="release.infoUrl"
:href="release.infoUrl"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('bookRequests.releases.releasePageLabel', { title: release.title, indexer: release.indexerName })"
class="rounded-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{{ release.title }}
<ArrowUpRight :size="13" class="inline-block align-[-2px] text-muted-foreground" aria-hidden="true" />
</a>
<template v-else>{{ release.title }}</template>
</p>
<!--
Two runs, not one. The chips say where the release comes from and what
@@ -84,7 +84,7 @@ const { load: loadDefaultDestination, defaultFor: instanceDefaultFor, resolveDes
const { progressByRequest, onRequestsChanged, pruneSettledProgress } = useBookRequestProgress()
const actions = useBookRequestActions(canManageFn)
const picker = useFulfilmentPicker()
const { review, fetchReview, reset: resetReview } = useRequestReview(canManageFn)
const { review, coverUrl: reviewCoverUrl, fetchReview, reset: resetReview } = useRequestReview(canManageFn)
const decisionNote = ref('')
/**
@@ -128,7 +128,9 @@ const isHeld = computed(() => request.value?.status === 'needs_review')
* stays. It also stays for every ordinary failure, which has no panel at all.
*/
const failureText = computed(() => (request.value ? requestFailureText(request.value, (key, named) => t(key, named)) : null))
const showFailureReason = computed(() => failureText.value != null && !(isHeld.value && review.value?.verification != null))
const showFailureReason = computed(
() => failureText.value != null && !(isHeld.value && (review.value?.verification != null || review.value?.heldOnRequest === true)),
)
/** Only a request that stopped badly earns the destructive treatment; a hand-back is information. */
const failureIsAlarming = computed(() => request.value?.status === 'failed' || request.value?.status === 'needs_review')
/** Whoever is going to say it, the transfer line is not the one that should say it twice. */
@@ -777,6 +779,7 @@ function dockFileMeta(file: BookDockFile): string {
<RequestReviewPanel
v-if="isHeld && review"
:review="review"
:cover-url="reviewCoverUrl"
:can-manage="canFulfilThis"
:busy="busy"
@file="handleFileAnyway"
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, FileText, Image, Loader2, Minus, Paperclip, Tags, Trash2, X } from '@lucide/vue'
import { Check, ExternalLink, FileText, Image, Loader2, Minus, Paperclip, Tags, Trash2, X } from '@lucide/vue'
import type { BookRequestReview, BookRequestReviewFile, BookRequestVerificationRow } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { formatLanguageName, formatList, formatNumber } from '@/i18n/formatters'
import { formatBytes } from '@/lib/formatting'
const props = defineProps<{
review: BookRequestReview
/** The cover found in the imported file, or null where it carried none. */
coverUrl?: string | null
canManage: boolean
busy: boolean
}>()
@@ -35,6 +38,58 @@ const headline = computed(() => {
: t('bookRequests.review.scoreShort', { score: detail.score, threshold: detail.threshold })
})
/**
* "File it anyway" is an override, which is the wrong word for an import that passed and is only
* waiting because whoever sent it asked to look first.
*/
const fileIsOverride = computed(() => !props.review.heldOnRequest || verification.value?.passed === false)
interface EditionRow {
key: string
label: string
value: string
}
/** Only what the file states. A row of "Not set" for every fact an ebook never carries is noise. */
const editionRows = computed<EditionRow[]>(() => {
const edition = props.review.edition
if (!edition) return []
const rows: EditionRow[] = []
const add = (key: string, value: string | null) => {
if (value) rows.push({ key, label: t(`bookRequests.review.edition.${key}`), value })
}
add('publisher', edition.publisher)
add('published', edition.publishedDate)
add('language', edition.language ? formatLanguageName(edition.language) : null)
add('pages', edition.pageCount !== null ? formatNumber(edition.pageCount) : null)
add('isbn10', edition.isbn10)
add('narrators', edition.narrators.length ? formatList(edition.narrators) : null)
add('length', edition.durationSeconds !== null ? formatDuration(edition.durationSeconds) : null)
add('version', edition.abridged === null ? null : t(edition.abridged ? 'bookRequests.review.abridged' : 'bookRequests.review.unabridged'))
add('chapters', edition.chapterCount !== null ? formatNumber(edition.chapterCount) : null)
return rows
})
/** A cover the server listed can still fail to load; the facts beside it stand on their own. */
const coverFailed = ref(false)
watch(
() => props.coverUrl,
() => {
coverFailed.value = false
},
)
const showCover = computed(() => Boolean(props.coverUrl) && !coverFailed.value)
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.round((seconds % 3600) / 60)
return hours > 0 ? t('bookRequests.releases.durationHm', { hours, minutes }) : t('bookRequests.releases.durationM', { minutes })
}
function handleCoverError() {
coverFailed.value = true
}
const filesSummary = computed(() => {
const count = t('bookRequests.review.fileCount', { count: props.review.files.length })
return props.review.totalSizeBytes === null ? count : `${count} · ${formatBytes(props.review.totalSizeBytes)}`
@@ -64,7 +119,20 @@ function handleDiscard() {
<template>
<section class="rounded-lg border border-border bg-card p-4" :aria-label="t('bookRequests.review.title')">
<h3 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{{ t('bookRequests.review.title') }}</h3>
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h3 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{{ t('bookRequests.review.title') }}</h3>
<!-- The source's own description of what was sent, to hold the file below up against. -->
<a
v-if="props.review.releaseInfoUrl"
:href="props.review.releaseInfoUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 rounded-sm text-xs text-foreground hover:text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<span class="underline underline-offset-2">{{ t('bookRequests.review.releasePage') }}</span>
<ExternalLink :size="12" class="shrink-0" aria-hidden="true" />
</a>
</div>
<template v-if="entryGone">
<p class="mt-2 text-sm text-foreground">{{ t('bookRequests.review.missingEntry') }}</p>
@@ -72,7 +140,8 @@ function handleDiscard() {
</template>
<template v-else>
<p class="mt-2 text-sm text-foreground">{{ headline }}</p>
<p v-if="props.review.heldOnRequest" class="mt-2 text-sm text-foreground">{{ t('bookRequests.review.heldOnRequest') }}</p>
<p class="text-sm" :class="props.review.heldOnRequest ? 'mt-1 text-muted-foreground' : 'mt-2 text-foreground'">{{ headline }}</p>
<p v-if="verification" class="mt-1 text-sm text-muted-foreground">{{ t(`bookRequests.review.reason.${verification.reason}`) }}</p>
<dl v-if="verification" class="mt-4 grid gap-3">
@@ -99,6 +168,27 @@ function handleDiscard() {
</div>
</dl>
<template v-if="editionRows.length || showCover">
<h4 class="mt-4 border-t border-border pt-3 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{{ t('bookRequests.review.editionTitle') }}
</h4>
<div class="mt-2 flex items-start gap-3">
<img
v-if="showCover"
:src="props.coverUrl ?? undefined"
:alt="t('bookRequests.review.importedCover')"
class="h-[90px] w-[60px] shrink-0 rounded-sm border border-border object-cover"
@error="handleCoverError"
/>
<dl v-if="editionRows.length" class="grid min-w-0 flex-1 grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
<template v-for="row in editionRows" :key="row.key">
<dt class="text-xs text-muted-foreground">{{ row.label }}</dt>
<dd class="break-words text-sm text-foreground">{{ row.value }}</dd>
</template>
</dl>
</div>
</template>
<h4 class="mt-4 border-t border-border pt-3 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{{ t('bookRequests.review.filesTitle') }}
</h4>
@@ -119,7 +209,7 @@ function handleDiscard() {
<div class="mt-3 flex flex-wrap items-center gap-3">
<Button :disabled="!props.review.canFile || props.busy" @click="handleFile">
<Loader2 v-if="props.busy" class="size-4 animate-spin" aria-hidden="true" />
{{ t('bookRequests.review.fileAnyway') }}
{{ fileIsOverride ? t('bookRequests.review.fileAnyway') : t('bookRequests.review.fileIt') }}
</Button>
<!--
The other answer, beside the first rather than behind a menu: a held import is a
@@ -137,7 +227,9 @@ function handleDiscard() {
{{ t('bookRequests.review.openDock') }}
</RouterLink>
</div>
<p class="mt-2 text-xs text-muted-foreground">{{ t('bookRequests.review.fileAnywayHint') }}</p>
<p class="mt-2 text-xs text-muted-foreground">
{{ fileIsOverride ? t('bookRequests.review.fileAnywayHint') : t('bookRequests.review.fileItHint') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('bookRequests.review.discardHint') }}</p>
</template>
</template>
@@ -67,6 +67,12 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
* called yet while the inspections run.
*/
const grabbing = ref(false)
/**
* Whether whatever this picker sends waits in review once it lands, whatever its score. One
* choice for the visit rather than one per row: the doubt it answers is about the request, and
* a magnet pasted by hand is just as likely to be the wrong edition as a ranked release.
*/
const holdForReview = ref(false)
function openManual() {
manualOpen.value = true
@@ -84,7 +90,7 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
/** A sent release leaves nothing to pick, so the drawer pops back to the request it belongs to, unless told otherwise. */
async function sendGrab(body: GrabBookRequestPayload, delivery: DownloadDelivery) {
if (!request.value) return
const outcome = await grab(request.value.id, body)
const outcome = await grab(request.value.id, holdForReview.value ? { ...body, holdForReview: true } : body)
if (!outcome.item) {
rememberRefusal(body, delivery, outcome.errorCode)
toast.error(t('bookRequests.errors.grabFailed'), outcome.reason ? { description: outcome.reason } : undefined)
@@ -150,5 +156,18 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
return key ? t(`bookRequests.releases.refused.${key}`) : null
}
return { manualOpen, grabbing, refusals, openManual, closeManual, forgetRefusals, submitGrab, handleGrab, handleManualGrab, isRefused, refusalText }
return {
manualOpen,
grabbing,
holdForReview,
refusals,
openManual,
closeManual,
forgetRefusals,
submitGrab,
handleGrab,
handleManualGrab,
isRefused,
refusalText,
}
}
@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { api } from '@/lib/api'
import { fulfilmentBase } from '../fulfilmentBase'
import type { BookRequestReview } from '@bookorbit/types'
@@ -44,6 +44,16 @@ export function useRequestReview(canManage: () => boolean) {
}
}
/**
* Keyed on the dock entry as well as the request: discarding an import and grabbing another
* leaves the request id unchanged, and the browser would otherwise keep showing the first cover.
*/
const coverUrl = computed(() => {
const current = review.value
if (!current?.hasCover || current.bookDockFileId === null) return null
return `${basePath()}/${current.requestId}/review/cover?entry=${current.bookDockFileId}`
})
function reset(): void {
generation++
review.value = null
@@ -51,5 +61,5 @@ export function useRequestReview(canManage: () => boolean) {
failed.value = false
}
return { review, loading, failed, fetchReview, reset }
return { review, coverUrl, loading, failed, fetchReview, reset }
}
+21
View File
@@ -8499,6 +8499,8 @@
"clientUnavailable": "Your download client did not answer an earlier release"
},
"singleFileNote": "A release can be one book in several formats, an audiobook in parts, or an archive - all of those import as a single book. A release holding several separate books asks you which one to keep.",
"holdForReview": "Review the download before it is filed",
"releasePageLabel": "{title}, release page on {indexer}",
"searching": "Searching indexers...",
"refresh": "Search again",
"cached": "Showing the last search. Search again for fresh results.",
@@ -8816,6 +8818,7 @@
"scoreShort": "The imported file scored {score} against this request, below the {threshold} needed to file it automatically.",
"scoreClears": "The imported file scored {score}, which clears the {threshold} needed to file it automatically.",
"verificationOff": "Import checking is off, so this file was never scored against the request.",
"heldOnRequest": "This release was sent to be checked before filing, so it is waiting for you.",
"reason": {
"isbn_match": "Its ISBN is the one that was requested.",
"above_threshold": "It looks like the book that was asked for.",
@@ -8835,8 +8838,24 @@
},
"requested": "Requested",
"imported": "Imported",
"releasePage": "Open the release page",
"absent": "Neither the request nor the file has one.",
"notSet": "Not set",
"editionTitle": "What the file says it is",
"edition": {
"publisher": "Publisher",
"published": "Published",
"language": "Language",
"pages": "Pages",
"isbn10": "ISBN-10",
"narrators": "Narrated by",
"length": "Length",
"version": "Version",
"chapters": "Chapters"
},
"abridged": "Abridged",
"unabridged": "Unabridged",
"importedCover": "Cover found in the imported file",
"filesTitle": "What landed",
"fileCount": "{count, plural, one {# file} other {# files}}",
"role": {
@@ -8846,6 +8865,8 @@
},
"fileAnyway": "File it anyway",
"fileAnywayHint": "Files this into the destination library and marks the request available, exactly as a passing score would have.",
"fileIt": "File it",
"fileItHint": "Files this into the destination library and marks the request available.",
"noDestination": "This request has no destination library with a folder in it, so there is nowhere to file the book yet.",
"openDock": "Open the Book Dock",
"missingEntry": "The file this request was held over is no longer in the Book Dock. It was filed or discarded by hand, so there is nothing left here to review.",