fix: remove KoreaderBookSyncCard, fix missing initial library load, add mediaSrc CSP, return null from getBookProgress instead of 404

This commit is contained in:
neon
2026-05-12 05:30:04 +00:00
parent 6a78df6597
commit 6cc9867a63
11 changed files with 31 additions and 130 deletions
@@ -15,7 +15,6 @@ import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/fe
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
import DiscoverRow from '@/features/book/components/detail/DiscoverRow.vue'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import KoreaderBookSyncCard from '@/features/koreader/components/KoreaderBookSyncCard.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -1404,13 +1403,6 @@ watch(
</div>
</div>
<KoreaderBookSyncCard
v-if="canViewKoreader && koreaderBookProgress"
class="mt-6"
:book-id="props.book.id"
:book-progress="koreaderBookProgress"
/>
<!-- Synopsis -->
<div class="mt-6 pt-5 border-t border-border">
<p class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground mb-3">Synopsis</p>
@@ -293,10 +293,11 @@ describe('useLibraryBooks', () => {
it('includes filter, collapseSeries, and trimmed q in the request body', async () => {
const { useLibraryBooks } = await import('../useLibraryBooks')
const libraryId = ref<number | null>(7)
const libraryId = ref<number | null>(null)
const collapseEnabled = ref(true)
const q = ref(' needle ')
const { filter, sort, load } = useLibraryBooks(libraryId, collapseEnabled, q)
const { filter, sort } = useLibraryBooks(libraryId, collapseEnabled, q)
filter.value = {
type: 'group',
join: 'AND',
@@ -305,7 +306,8 @@ describe('useLibraryBooks', () => {
sort.value = [{ field: 'author', dir: 'desc' }]
mockOkResponse(makePage([], 0))
await load(true)
libraryId.value = 7
await new Promise((r) => setTimeout(r, 0))
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('/api/v1/libraries/7/books')
@@ -141,12 +141,8 @@ export function useLibraryBooks(libraryId: Ref<number | null>, collapseEnabled:
pagination.page = 0
}
watch(libraryId, (newId, oldId) => {
if (newId !== oldId) load(true)
})
watch(
sort,
[libraryId, sort] as const,
() => {
void load(true)
},
@@ -1,80 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { Smartphone, AlertTriangle } from 'lucide-vue-next'
import type { KoreaderBookSyncInfo } from '@bookorbit/types'
import { useKoreaderBookProgress } from '@/features/koreader/composables/useKoreaderBookProgress'
const props = defineProps<{ bookId: number; bookProgress?: KoreaderBookSyncInfo | null }>()
const { bookProgress: fetchedProgress, loading, fetchBookProgress } = useKoreaderBookProgress()
const displayProgress = computed(() => props.bookProgress ?? fetchedProgress.value)
const isLoading = computed(() => (props.bookProgress === undefined ? loading.value : false))
onMounted(() => {
if (props.bookProgress === undefined) {
fetchBookProgress(props.bookId)
}
})
watch(
() => props.bookId,
(newId) => {
if (props.bookProgress === undefined) {
fetchBookProgress(newId)
}
},
)
function formatPercentage(pct: number): string {
return `${Math.round(pct)}%`
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
</script>
<template>
<div v-if="!isLoading && displayProgress" class="border border-border rounded-lg bg-card shadow-xs overflow-hidden">
<div class="px-4 py-3 border-b border-border flex items-center justify-between">
<div class="flex items-center gap-2">
<Smartphone :size="14" class="text-muted-foreground" />
<span class="text-xs font-medium text-foreground">KOReader Sync</span>
</div>
<div v-if="displayProgress.fileModifiedSinceLastSync" class="flex items-center gap-1 text-amber-500">
<AlertTriangle :size="12" />
<span class="text-[10px] font-medium">File modified</span>
</div>
</div>
<div class="px-4 py-3 space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Progress</span>
<span class="text-xs font-medium text-foreground">{{ formatPercentage(displayProgress.canonicalPercentage) }}</span>
</div>
<div class="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-primary rounded-full transition-all" :style="{ width: `${displayProgress.canonicalPercentage}%` }" />
</div>
<div v-if="displayProgress.canonicalChapterTitle" class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Chapter</span>
<span class="text-xs text-foreground truncate max-w-[60%]">{{ displayProgress.canonicalChapterTitle }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Source</span>
<span class="text-xs text-foreground">{{ displayProgress.canonicalSource === 'koreader' ? 'KOReader' : 'Web Reader' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Updated</span>
<span class="text-xs text-foreground">{{ formatDate(displayProgress.canonicalUpdatedAt) }}</span>
</div>
<!-- Per-device breakdown -->
<div v-if="displayProgress.devices.length > 1" class="pt-2 border-t border-border space-y-1.5">
<p class="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Devices</p>
<div v-for="device in displayProgress.devices" :key="device.deviceId" class="flex items-center justify-between text-xs">
<span class="text-muted-foreground truncate max-w-[40%]">{{ device.device }}</span>
<span class="text-foreground">{{ formatPercentage(device.percentage) }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -80,8 +80,20 @@ describe('useKoreaderBookProgress', () => {
expect(loading.value).toBe(false)
})
it('fetchBookProgress sets bookProgress to null on 404', async () => {
apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 404 }))
it('fetchBookProgress sets bookProgress to null when server returns null (no koreader data)', async () => {
apiMock.mockResolvedValueOnce(makeResponse(null))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, fetchBookProgress } = useKoreaderBookProgress()
bookProgress.value = makeBookProgress()
await fetchBookProgress(42)
expect(bookProgress.value).toBeNull()
})
it('fetchBookProgress sets bookProgress to null on error response', async () => {
apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 500 }))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, fetchBookProgress } = useKoreaderBookProgress()
@@ -10,10 +10,6 @@ export function useKoreaderBookProgress() {
loading.value = true
try {
const res = await api(`/api/v1/koreader/books/${bookId}/progress`)
if (res.status === 404) {
bookProgress.value = null
return
}
if (!res.ok) throw new Error('Failed to fetch book progress')
bookProgress.value = await res.json()
} catch {
@@ -98,21 +98,21 @@ describe('useFoliate.open', () => {
expect(mockGoToFraction).toHaveBeenCalledWith(0.42)
})
it('does not navigate when cfi is null and fraction is 0', async () => {
it('navigates to position 0 when cfi is null and fraction is 0', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', null, 0)
expect(mockGoTo).not.toHaveBeenCalled()
expect(mockGoTo).toHaveBeenCalledWith(0)
expect(mockGoToFraction).not.toHaveBeenCalled()
})
it('does not navigate when cfi is null and fraction is undefined', async () => {
it('navigates to position 0 when cfi is null and fraction is undefined', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', null, undefined)
expect(mockGoTo).not.toHaveBeenCalled()
expect(mockGoTo).toHaveBeenCalledWith(0)
expect(mockGoToFraction).not.toHaveBeenCalled()
})
@@ -136,6 +136,8 @@ export function useFoliate(
await view.goTo(cfi).catch(() => {})
} else if (fallbackFraction !== undefined && fallbackFraction > 0) {
view.goToFraction?.(fallbackFraction)
} else {
await view.goTo(0).catch(() => {})
}
} catch (e) {
console.error('[useFoliate]', e)
+1 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue'
import { computed, onUnmounted, provide, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowUpDown,
@@ -339,10 +339,6 @@ const { sentinel } = useInfiniteScrollSentinel({
loadMore: () => load(),
})
onMounted(() => {
load(true)
})
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0) return
if (libraryId.value === event.libraryId) {
+1
View File
@@ -103,6 +103,7 @@ async function bootstrap() {
styleSrc: ["'self'", "'unsafe-inline'", 'blob:'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: cspConnectSrc,
mediaSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:', 'blob:'],
objectSrc: ["'none'"],
frameSrc: ["'self'", 'blob:'],
@@ -1,18 +1,4 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Headers,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, ParseIntPipe, Patch, Post, Put, UseGuards } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -100,10 +86,8 @@ export class KoreaderController {
@RequirePermission(Permission.KoreaderSync)
@Get('books/:bookId/progress')
async getBookProgress(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
const progress = await this.koreaderService.getBookProgress(user.id, bookId);
if (!progress) throw new NotFoundException('No KOReader sync data found for this book');
return progress;
getBookProgress(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
return this.koreaderService.getBookProgress(user.id, bookId);
}
@RequirePermission(Permission.KoreaderSync)