mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(koreader): hide audiobook-only books from the KOReader plugin
Add a per-login setting, on by default, that leaves books whose content files are all audio out of the plugin catalog, its counts and bulk downloads, and drops a library holding nothing else. The plugin detail page also names the first file the device can open as the main file. Closes #1345
This commit is contained in:
@@ -18,6 +18,7 @@ function makeCredentials(overrides: Partial<KoreaderCredentials> = {}): Koreader
|
||||
return {
|
||||
username: 'reader-user',
|
||||
syncEnabled: true,
|
||||
hideAudioOnlyBooks: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ onUnmounted(() => {
|
||||
const syncUrl = computed(() => new URL('/api/v1/koreader', getSyncUrl()).toString())
|
||||
const { supported: deliverySupported } = useReadAloudDelivery('koreader-delivery-settings')
|
||||
const newReadAloudDelivery = ref<ReadAloudDeliveryPreference>('audioless_epub')
|
||||
const savingHideAudioOnlyBooks = ref(false)
|
||||
|
||||
const hasCredentials = computed(() => !!credentials.value)
|
||||
const activeDevices = computed(() => (syncStatus.value?.devices ?? []).filter((device) => device.retiredAt === null))
|
||||
@@ -422,6 +423,18 @@ async function handleToggleSync(newValue: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleHideAudioOnlyBooks(newValue: boolean) {
|
||||
savingHideAudioOnlyBooks.value = true
|
||||
try {
|
||||
await updateCredentials({ hideAudioOnlyBooks: newValue })
|
||||
toast.success(newValue ? t('settings.reader.koreader.audioOnlyBooksHidden') : t('settings.reader.koreader.audioOnlyBooksShown'))
|
||||
} catch {
|
||||
toast.error(t('settings.reader.koreader.toggleAudioOnlyBooksFailed'))
|
||||
} finally {
|
||||
savingHideAudioOnlyBooks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteCredentials()
|
||||
@@ -870,6 +883,32 @@ async function handleDownloadPlugin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<p class="settings-group-label">
|
||||
{{ t('settings.reader.koreader.pluginLibrary') }}
|
||||
</p>
|
||||
<div class="settings-card">
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
|
||||
<div class="min-w-0">
|
||||
<p id="koreader-hide-audio-only-label" class="settings-label">
|
||||
{{ t('settings.reader.koreader.hideAudioOnlyBooks') }}
|
||||
</p>
|
||||
<p id="koreader-hide-audio-only-hint" class="settings-hint">
|
||||
{{ t('settings.reader.koreader.hideAudioOnlyBooksHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="credentials?.hideAudioOnlyBooks ?? true"
|
||||
:disabled="savingHideAudioOnlyBooks"
|
||||
aria-labelledby="koreader-hide-audio-only-label"
|
||||
aria-describedby="koreader-hide-audio-only-hint"
|
||||
class="self-start md:self-auto"
|
||||
@update:model-value="handleToggleHideAudioOnlyBooks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<p class="settings-group-label">
|
||||
{{ t('settings.reader.koreader.setup') }}
|
||||
|
||||
@@ -17,7 +17,7 @@ const device: KoreaderDeviceInfo = {
|
||||
function createWrapper() {
|
||||
return mount(KoreaderDeliverySettings, {
|
||||
props: {
|
||||
credentials: { username: 'reader', syncEnabled: true, createdAt: '', readAloudDelivery: 'audioless_epub' },
|
||||
credentials: { username: 'reader', syncEnabled: true, hideAudioOnlyBooks: true, createdAt: '', readAloudDelivery: 'audioless_epub' },
|
||||
devices: [device],
|
||||
sweeps: [],
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import KoreaderSettings from '../KoreaderSettings.vue'
|
||||
import type { BookCard, KoreaderCredentials, KoreaderManualHashLink, KoreaderSyncStatus, KoreaderUnmatchedBook } from '@bookorbit/types'
|
||||
import { copyToClipboard } from '@/lib/clipboard'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const routerState = vi.hoisted(() => ({
|
||||
currentQuery: {} as Record<string, string>,
|
||||
@@ -78,6 +79,7 @@ function makeCredentials(overrides: Partial<KoreaderCredentials> = {}): Koreader
|
||||
return {
|
||||
username: 'reader-user',
|
||||
syncEnabled: true,
|
||||
hideAudioOnlyBooks: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
@@ -316,7 +318,7 @@ describe('KoreaderSettings', () => {
|
||||
expect(koreaderMock.createCredentials).toHaveBeenCalledWith({ username: 'new-reader', password: 'secret1' })
|
||||
})
|
||||
|
||||
it('renders configured status, setup, device, activity, guide, and danger sections', async () => {
|
||||
it('renders configured status, plugin library, setup, device, activity, guide, and danger sections', async () => {
|
||||
const status = makeSyncStatus()
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
@@ -326,6 +328,7 @@ describe('KoreaderSettings', () => {
|
||||
|
||||
expect(wrapper.findAll('.settings-group-label').map((label) => label.text())).toEqual([
|
||||
'KOReader Status',
|
||||
'Plugin Library',
|
||||
'Setup',
|
||||
'Devices',
|
||||
'Plugin Activity',
|
||||
@@ -562,6 +565,47 @@ describe('KoreaderSettings', () => {
|
||||
expect(koreaderMock.deleteCredentials).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('names the audiobook-only switch and saves the reader turning it off', async () => {
|
||||
const status = makeSyncStatus()
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const toggle = wrapper.get('[aria-labelledby="koreader-hide-audio-only-label"]')
|
||||
expect(toggle.attributes('role')).toBe('switch')
|
||||
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||
expect(wrapper.get('#koreader-hide-audio-only-label').text()).toBe('Hide audiobook-only books')
|
||||
expect(wrapper.find(`#${toggle.attributes('aria-describedby')}`).exists()).toBe(true)
|
||||
|
||||
await toggle.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({ hideAudioOnlyBooks: false })
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Audiobook-only books shown in the plugin')
|
||||
expect(toggle.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a failed audiobook-only switch and lets the reader try again', async () => {
|
||||
const status = makeSyncStatus()
|
||||
koreaderMock.credentials.value = { ...status.credentials!, hideAudioOnlyBooks: false }
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.updateCredentials.mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const toggle = wrapper.get('[aria-labelledby="koreader-hide-audio-only-label"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('false')
|
||||
await toggle.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({ hideAudioOnlyBooks: true })
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to update the plugin library setting')
|
||||
expect(toggle.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens the unmatched link dialog and submits the selected BookOrbit book', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
|
||||
@@ -3407,6 +3407,12 @@
|
||||
"syncEnabled": "KOReader sync enabled",
|
||||
"syncDisabled": "KOReader sync disabled",
|
||||
"toggleSyncFailed": "Failed to toggle sync",
|
||||
"pluginLibrary": "Plugin Library",
|
||||
"hideAudioOnlyBooks": "Hide audiobook-only books",
|
||||
"hideAudioOnlyBooksHint": "KOReader cannot play audio, so books with only audio files stay out of the plugin's lists, search and downloads, as does a library holding nothing else. Books that also have an ebook still show.",
|
||||
"audioOnlyBooksHidden": "Audiobook-only books hidden in the plugin",
|
||||
"audioOnlyBooksShown": "Audiobook-only books shown in the plugin",
|
||||
"toggleAudioOnlyBooksFailed": "Failed to update the plugin library setting",
|
||||
"syncUrlCopied": "Sync URL copied to clipboard",
|
||||
"syncUrlCopyFailed": "Failed to copy sync URL",
|
||||
"statusRefreshed": "Sync status refreshed",
|
||||
|
||||
Reference in New Issue
Block a user