fix(books): switch progress reset to per-file controls (#63)

* fix(books): switch progress reset to per-file controls

Remove the book-level reset action and endpoint, and keep reset scoped to each file row to avoid accidental bulk clears.

Add tests for per-file reset behavior and progress formatting edge cases while keeping migration import filters aligned with the new progress expectations.

closes #57

* ci: increase commitlint body line limit to 250

Allow longer explanatory commit bodies so CI accepts existing project commit patterns without forcing manual hard wraps at 100 characters.
This commit is contained in:
neon
2026-05-20 12:40:24 -06:00
committed by GitHub
parent bb11fc2abc
commit e11b22f2b0
11 changed files with 434 additions and 61 deletions
@@ -187,4 +187,128 @@ describe('DetailsTab — present state', () => {
expect(wrapper.text()).toContain('Favorites') expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).not.toContain('Want to Read') expect(wrapper.text()).not.toContain('Want to Read')
}) })
it('formats tiny non-zero progress as <1%', async () => {
vi.mocked(api).mockImplementation(async (input) => {
if (input === '/api/v1/books/1/progress') {
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 0.4, updatedAt: null }])
}
if (input === '/api/v1/collections?bookIds=1') {
return makeApiResponse([])
}
return makeApiResponse({}, false)
})
const wrapper = mount(DetailsTab, {
props: {
book: makeBook({
files: [
{
id: 101,
format: 'epub',
role: 'primary',
sizeBytes: 1234,
absolutePath: '/books/test.epub',
createdAt: '2024-01-01T00:00:00.000Z',
filename: 'test.epub',
durationSeconds: null,
},
],
}),
},
global: globalStubs,
})
await flushPromises()
expect(wrapper.text()).toContain('<1%')
})
it('formats near-complete progress as >99%', async () => {
vi.mocked(api).mockImplementation(async (input) => {
if (input === '/api/v1/books/1/progress') {
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 99.6, updatedAt: null }])
}
if (input === '/api/v1/collections?bookIds=1') {
return makeApiResponse([])
}
return makeApiResponse({}, false)
})
const wrapper = mount(DetailsTab, {
props: {
book: makeBook({
files: [
{
id: 101,
format: 'epub',
role: 'primary',
sizeBytes: 1234,
absolutePath: '/books/test.epub',
createdAt: '2024-01-01T00:00:00.000Z',
filename: 'test.epub',
durationSeconds: null,
},
],
}),
},
global: globalStubs,
})
await flushPromises()
expect(wrapper.text()).toContain('>99%')
})
it('resets a single file progress row from the inline control', async () => {
let progressRows: Array<{ fileId: number; cfi: string | null; pageNumber: number | null; percentage: number; updatedAt: string | null }> = [
{ fileId: 101, cfi: null, pageNumber: null, percentage: 22, updatedAt: null },
]
vi.mocked(api).mockImplementation(async (input, init) => {
if (input === '/api/v1/books/files/101/progress' && init?.method === 'DELETE') {
progressRows = []
return makeApiResponse({})
}
if (input === '/api/v1/books/1/progress') {
return makeApiResponse(progressRows)
}
if (input === '/api/v1/collections?bookIds=1') {
return makeApiResponse([])
}
return makeApiResponse({}, false)
})
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mount(DetailsTab, {
props: {
book: makeBook({
files: [
{
id: 101,
format: 'epub',
role: 'primary',
sizeBytes: 1234,
absolutePath: '/books/test.epub',
createdAt: '2024-01-01T00:00:00.000Z',
filename: 'test.epub',
durationSeconds: null,
},
],
}),
},
global: globalStubs,
})
await flushPromises()
const fileResetButton = wrapper.find('button[aria-label="Reset file progress"]')
expect(fileResetButton.exists()).toBe(true)
await fileResetButton.trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalled()
expect(vi.mocked(api)).toHaveBeenCalledWith('/api/v1/books/files/101/progress', { method: 'DELETE' })
confirmSpy.mockRestore()
})
}) })
@@ -1,7 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { BookOpen, Check, ChevronDown, FolderPlus, Headphones, Lock, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next' import {
BookOpen,
Check,
ChevronDown,
FolderPlus,
Headphones,
Lock,
MoreHorizontal,
Pencil,
RotateCcw,
Star,
Trash2,
TriangleAlert,
X,
} from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui' import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { bookCoverStyle } from '@/features/book/lib/book-cover' import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { getFormatColor } from '@/features/book/lib/format-colors' import { getFormatColor } from '@/features/book/lib/format-colors'
@@ -351,6 +365,7 @@ const audiobookProgress = ref<{ percentage: number; currentFileId: number; posit
const collections = ref<CollectionMembership[]>([]) const collections = ref<CollectionMembership[]>([])
const koboState = ref<BookKoboState | null>(null) const koboState = ref<BookKoboState | null>(null)
const supplementalLoading = ref(false) const supplementalLoading = ref(false)
const resettingFileIds = ref<number[]>([])
const providerIconErrors = ref<Record<string, boolean>>({}) const providerIconErrors = ref<Record<string, boolean>>({})
const providerLinks = computed<ProviderLink[]>(() => { const providerLinks = computed<ProviderLink[]>(() => {
@@ -441,8 +456,8 @@ type ProgressRow = {
percentage: number percentage: number
color: string color: string
badgeStyle: Record<string, string> badgeStyle: Record<string, string>
tooltipText: string
finished: boolean finished: boolean
resetFileId: number | null
} }
const KOBO_COLOR = '#f59e0b' const KOBO_COLOR = '#f59e0b'
@@ -457,8 +472,8 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
percentage: progress.percentage, percentage: progress.percentage,
color, color,
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` }, badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
tooltipText: file.absolutePath,
finished: progress.percentage >= 100, finished: progress.percentage >= 100,
resetFileId: file.id,
}) })
} }
@@ -471,20 +486,19 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
percentage: audiobookProgress.value.percentage, percentage: audiobookProgress.value.percentage,
color, color,
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` }, badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
tooltipText: audioFile?.absolutePath ?? '',
finished: audiobookProgress.value.percentage >= 100, finished: audiobookProgress.value.percentage >= 100,
resetFileId: audiobookProgress.value.currentFileId,
}) })
} }
const koboPercent = koboState.value?.readingState?.progressPercent const koboPercent = koboState.value?.readingState?.progressPercent
if (canViewKobo.value && koboPercent != null && koboPercent > 0) { if (canViewKobo.value && koboPercent != null && koboPercent > 0) {
const syncCols = koboState.value?.syncCollections ?? []
rows.push({ rows.push({
label: 'Kobo', label: 'Kobo',
percentage: koboPercent, percentage: koboPercent,
color: KOBO_COLOR, color: KOBO_COLOR,
badgeStyle: { color: KOBO_COLOR, borderColor: `${KOBO_COLOR}66`, backgroundColor: `${KOBO_COLOR}1a` }, badgeStyle: { color: KOBO_COLOR, borderColor: `${KOBO_COLOR}66`, backgroundColor: `${KOBO_COLOR}1a` },
tooltipText: syncCols.length > 0 ? `Via: ${syncCols.join(', ')}` : 'Kobo device',
finished: koboPercent >= 100, finished: koboPercent >= 100,
resetFileId: null,
}) })
} }
if (canViewKoreader.value && koreaderBookProgress.value != null && koreaderBookProgress.value.canonicalPercentage > 0) { if (canViewKoreader.value && koreaderBookProgress.value != null && koreaderBookProgress.value.canonicalPercentage > 0) {
@@ -494,8 +508,8 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
percentage: koreaderBookProgress.value.canonicalPercentage, percentage: koreaderBookProgress.value.canonicalPercentage,
color: koreaderColor, color: koreaderColor,
badgeStyle: { color: koreaderColor, borderColor: `${koreaderColor}66`, backgroundColor: `${koreaderColor}1a` }, badgeStyle: { color: koreaderColor, borderColor: `${koreaderColor}66`, backgroundColor: `${koreaderColor}1a` },
tooltipText: 'KOReader device sync',
finished: koreaderBookProgress.value.canonicalPercentage >= 100, finished: koreaderBookProgress.value.canonicalPercentage >= 100,
resetFileId: null,
}) })
} }
return rows return rows
@@ -525,7 +539,10 @@ function formatDateTime(iso: string): string {
} }
function formatPercent(value: number): string { function formatPercent(value: number): string {
return `${Math.max(0, Math.min(100, Math.round(value)))}%` const clamped = Math.max(0, Math.min(100, value))
if (clamped > 0 && clamped < 1) return '<1%'
if (clamped > 99 && clamped < 100) return '>99%'
return `${Math.round(clamped)}%`
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@@ -599,6 +616,34 @@ function openBookFile(file: BookDetail['files'][number]) {
}) })
} }
function isResettingFile(fileId: number | null): boolean {
return fileId != null && resettingFileIds.value.includes(fileId)
}
function setFileResetting(fileId: number, resetting: boolean): void {
if (resetting) {
if (resettingFileIds.value.includes(fileId)) return
resettingFileIds.value = [...resettingFileIds.value, fileId]
return
}
resettingFileIds.value = resettingFileIds.value.filter((id) => id !== fileId)
}
async function handleResetFileProgress(row: ProgressRow) {
const fileId = row.resetFileId
if (fileId == null || isResettingFile(fileId)) return
if (!window.confirm(`Reset stored reading progress for ${row.label}?`)) return
setFileResetting(fileId, true)
try {
const res = await api(`/api/v1/books/files/${fileId}/progress`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to reset file progress')
await loadSupplemental()
} finally {
setFileResetting(fileId, false)
}
}
let supplementalRequestId = 0 let supplementalRequestId = 0
async function loadSupplemental() { async function loadSupplemental() {
@@ -1057,30 +1102,38 @@ watch(
</div> </div>
</div> </div>
<div v-if="leftColumnProgressVisible.length" class="mt-4 space-y-2"> <div v-if="leftColumnProgressVisible.length" class="mt-4 space-y-2">
<Tooltip v-for="row in leftColumnProgressVisible" :key="row.label"> <div v-for="row in leftColumnProgressVisible" :key="row.label" class="flex items-center gap-2 cursor-default">
<TooltipTrigger as-child> <span
<div class="flex items-center gap-2 cursor-default"> class="w-11 shrink-0 text-center text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border"
<span :style="row.badgeStyle"
class="w-11 shrink-0 text-center text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border" >{{ row.label }}</span
:style="row.badgeStyle" >
>{{ row.label }}</span <div class="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full"
:style="{
width: `${Math.min(100, row.percentage)}%`,
backgroundColor: row.finished ? 'rgb(34 197 94 / 0.8)' : row.color,
opacity: row.finished ? '1' : '0.75',
}"
/>
</div>
<span v-if="row.finished" class="text-[11px] font-medium text-green-500 shrink-0">Finished</span>
<span v-else class="text-[11px] text-muted-foreground shrink-0 w-7 text-right">{{ formatPercent(row.percentage) }}</span>
<Tooltip v-if="row.resetFileId != null">
<TooltipTrigger as-child>
<button
class="ml-1 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Reset file progress"
:disabled="isResettingFile(row.resetFileId)"
@click.stop="void handleResetFileProgress(row)"
> >
<div class="flex-1 h-1 rounded-full bg-muted overflow-hidden"> <RotateCcw class="size-3" />
<div </button>
class="h-full rounded-full" </TooltipTrigger>
:style="{ <TooltipContent>{{ isResettingFile(row.resetFileId) ? 'Resetting...' : 'Reset file progress' }}</TooltipContent>
width: `${Math.min(100, row.percentage)}%`, </Tooltip>
backgroundColor: row.finished ? 'rgb(34 197 94 / 0.8)' : row.color, </div>
opacity: row.finished ? '1' : '0.75',
}"
/>
</div>
<span v-if="row.finished" class="text-[11px] font-medium text-green-500 shrink-0">Finished</span>
<span v-else class="text-[11px] text-muted-foreground shrink-0 w-7 text-right">{{ formatPercent(row.percentage) }}</span>
</div>
</TooltipTrigger>
<TooltipContent>{{ row.tooltipText }}</TooltipContent>
</Tooltip>
<p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p> <p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p>
</div> </div>
<div v-if="koboAnomaly" class="mt-2 flex items-center gap-1.5"> <div v-if="koboAnomaly" class="mt-2 flex items-center gap-1.5">
@@ -1350,30 +1403,38 @@ watch(
<!-- Mobile-only: reading progress + collections (relocated from left column) --> <!-- Mobile-only: reading progress + collections (relocated from left column) -->
<div v-if="leftColumnProgressVisible.length || koboAnomaly || collections.length" class="md:hidden mt-6 pt-5 border-t border-border space-y-3"> <div v-if="leftColumnProgressVisible.length || koboAnomaly || collections.length" class="md:hidden mt-6 pt-5 border-t border-border space-y-3">
<div v-if="leftColumnProgressVisible.length" class="space-y-2"> <div v-if="leftColumnProgressVisible.length" class="space-y-2">
<Tooltip v-for="row in leftColumnProgressVisible" :key="row.label"> <div v-for="row in leftColumnProgressVisible" :key="row.label" class="flex items-center gap-2 cursor-default">
<TooltipTrigger as-child> <span
<div class="flex items-center gap-2 cursor-default"> class="w-11 shrink-0 text-center text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border"
<span :style="row.badgeStyle"
class="w-11 shrink-0 text-center text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border" >{{ row.label }}</span
:style="row.badgeStyle" >
>{{ row.label }}</span <div class="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full"
:style="{
width: `${Math.min(100, row.percentage)}%`,
backgroundColor: row.finished ? 'rgb(34 197 94 / 0.8)' : row.color,
opacity: row.finished ? '1' : '0.75',
}"
/>
</div>
<span v-if="row.finished" class="text-[11px] font-medium text-green-500 shrink-0">Finished</span>
<span v-else class="text-[11px] text-muted-foreground shrink-0 w-7 text-right">{{ formatPercent(row.percentage) }}</span>
<Tooltip v-if="row.resetFileId != null">
<TooltipTrigger as-child>
<button
class="ml-1 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Reset file progress"
:disabled="isResettingFile(row.resetFileId)"
@click.stop="void handleResetFileProgress(row)"
> >
<div class="flex-1 h-1 rounded-full bg-muted overflow-hidden"> <RotateCcw class="size-3" />
<div </button>
class="h-full rounded-full" </TooltipTrigger>
:style="{ <TooltipContent>{{ isResettingFile(row.resetFileId) ? 'Resetting...' : 'Reset file progress' }}</TooltipContent>
width: `${Math.min(100, row.percentage)}%`, </Tooltip>
backgroundColor: row.finished ? 'rgb(34 197 94 / 0.8)' : row.color, </div>
opacity: row.finished ? '1' : '0.75',
}"
/>
</div>
<span v-if="row.finished" class="text-[11px] font-medium text-green-500 shrink-0">Finished</span>
<span v-else class="text-[11px] text-muted-foreground shrink-0 w-7 text-right">{{ formatPercent(row.percentage) }}</span>
</div>
</TooltipTrigger>
<TooltipContent>{{ row.tooltipText }}</TooltipContent>
</Tooltip>
<p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p> <p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p>
</div> </div>
<div v-if="koboAnomaly" class="flex items-center gap-1.5"> <div v-if="koboAnomaly" class="flex items-center gap-1.5">
+1
View File
@@ -3,6 +3,7 @@ module.exports = {
rules: { rules: {
"type-enum": [2, "always", ["feat", "fix", "db", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "security", "revert"]], "type-enum": [2, "always", ["feat", "fix", "db", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "security", "revert"]],
"header-max-length": [2, "always", 100], "header-max-length": [2, "always", 100],
"body-max-line-length": [2, "always", 250],
"subject-full-stop": [2, "never", "."], "subject-full-stop": [2, "never", "."],
"scope-enum": [0], "scope-enum": [0],
"scope-empty": [0], "scope-empty": [0],
@@ -132,6 +132,7 @@ function makeController() {
getBookProgress: vi.fn(), getBookProgress: vi.fn(),
getAudioProgress: vi.fn(), getAudioProgress: vi.fn(),
saveProgress: vi.fn(), saveProgress: vi.fn(),
clearFileProgress: vi.fn(),
saveAudioProgress: vi.fn(), saveAudioProgress: vi.fn(),
updateMetadata: vi.fn(), updateMetadata: vi.fn(),
updateMetadataLocks: vi.fn(), updateMetadataLocks: vi.fn(),
@@ -742,9 +743,11 @@ describe('BookController', () => {
const user = makeUser(); const user = makeUser();
await controller.saveFileProgress(9, { percentage: 25 } as never, user); await controller.saveFileProgress(9, { percentage: 25 } as never, user);
await controller.clearFileProgress(9, user);
await controller.saveAudioProgress(11, { currentFileId: 2, positionSeconds: 90, percentage: 20 } as never, user); await controller.saveAudioProgress(11, { currentFileId: 2, positionSeconds: 90, percentage: 20 } as never, user);
expect(bookService.saveProgress).toHaveBeenCalledWith(user.id, 9, { percentage: 25 }, user); expect(bookService.saveProgress).toHaveBeenCalledWith(user.id, 9, { percentage: 25 }, user);
expect(bookService.clearFileProgress).toHaveBeenCalledWith(user.id, 9, user);
expect(bookService.saveAudioProgress).toHaveBeenCalledWith(user.id, 11, { currentFileId: 2, positionSeconds: 90, percentage: 20 }, user); expect(bookService.saveAudioProgress).toHaveBeenCalledWith(user.id, 11, { currentFileId: 2, positionSeconds: 90, percentage: 20 }, user);
}); });
@@ -478,6 +478,12 @@ export class BookController {
await this.bookService.saveProgress(user.id, fileId, dto, user); await this.bookService.saveProgress(user.id, fileId, dto, user);
} }
@Delete('files/:fileId/progress')
@HttpCode(HttpStatus.NO_CONTENT)
async clearFileProgress(@Param('fileId', ParseIntPipe) fileId: number, @CurrentUser() user: RequestUser) {
await this.bookService.clearFileProgress(user.id, fileId, user);
}
@Get(':id/audio-progress') @Get(':id/audio-progress')
async getAudioProgress(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) { async getAudioProgress(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return (await this.bookService.getAudioProgress(user.id, id, user)) ?? null; return (await this.bookService.getAudioProgress(user.id, id, user)) ?? null;
@@ -537,4 +537,20 @@ describe('BookRepository', () => {
}), }),
); );
}); });
it('clears both reading_progress and audiobook_progress rows for a file id', async () => {
const readingWhere = vi.fn().mockResolvedValue(undefined);
const audioWhere = vi.fn().mockResolvedValue(undefined);
const del = vi.fn().mockReturnValueOnce({ where: readingWhere }).mockReturnValueOnce({ where: audioWhere });
const db = {
delete: del,
};
const repo = new BookRepository(db as never);
await repo.clearFileProgress(7, 99);
expect(del).toHaveBeenCalledTimes(2);
expect(readingWhere).toHaveBeenCalledTimes(1);
expect(audioWhere).toHaveBeenCalledTimes(1);
});
}); });
@@ -862,6 +862,11 @@ export class BookRepository {
}); });
} }
async clearFileProgress(userId: number, fileId: number): Promise<void> {
await this.db.delete(readingProgress).where(and(eq(readingProgress.userId, userId), eq(readingProgress.bookFileId, fileId)));
await this.db.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.currentFileId, fileId)));
}
async findAudioProgress(userId: number, bookId: number) { async findAudioProgress(userId: number, bookId: number) {
const [row] = await this.db const [row] = await this.db
.select() .select()
@@ -98,6 +98,7 @@ function makeService() {
findProgress: vi.fn(), findProgress: vi.fn(),
findProgressByBook: vi.fn(), findProgressByBook: vi.fn(),
upsertProgress: vi.fn(), upsertProgress: vi.fn(),
clearFileProgress: vi.fn(),
findAudioProgress: vi.fn(), findAudioProgress: vi.fn(),
upsertAudioProgress: vi.fn(), upsertAudioProgress: vi.fn(),
bulkSetRating: vi.fn(), bulkSetRating: vi.fn(),
@@ -1572,6 +1573,25 @@ describe('BookService', () => {
}); });
}); });
describe('clearFileProgress', () => {
it('verifies file access and clears file-scoped progress rows', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ id: 12 });
vi.spyOn(service, 'verifyFileAccess').mockResolvedValue({
id: 88,
absolutePath: '/books/sample.epub',
format: 'epub',
bookId: 77,
libraryId: 5,
});
await service.clearFileProgress(user.id, 88, user);
expect(service.verifyFileAccess).toHaveBeenCalledWith(88, user);
expect(bookRepo.clearFileProgress).toHaveBeenCalledWith(user.id, 88);
});
});
describe('saveAudioProgress', () => { describe('saveAudioProgress', () => {
it('writes audio progress when current file belongs to the target book', async () => { it('writes audio progress when current file belongs to the target book', async () => {
const { service, bookRepo, libraryService } = makeService(); const { service, bookRepo, libraryService } = makeService();
+5
View File
@@ -1300,6 +1300,11 @@ export class BookService {
.catch((err: Error) => this.logger.warn(`Auto status update failed for book ${file.bookId}: ${err.message}`)); .catch((err: Error) => this.logger.warn(`Auto status update failed for book ${file.bookId}: ${err.message}`));
} }
async clearFileProgress(userId: number, fileId: number, user: RequestUser): Promise<void> {
await this.verifyFileAccess(fileId, user);
await this.bookRepo.clearFileProgress(userId, fileId);
}
async setReadStatus(bookId: number, status: ReadStatus, user: RequestUser): Promise<void> { async setReadStatus(bookId: number, status: ReadStatus, user: RequestUser): Promise<void> {
await this.verifyBookAccess(bookId, user); await this.verifyBookAccess(bookId, user);
await this.userBookStatusService.setManual(user.id, bookId, status); await this.userBookStatusService.setManual(user.id, bookId, status);
@@ -404,4 +404,111 @@ describe('UserStateImporter', () => {
}), }),
); );
}); });
it('skips noise progress rows that have no percentage, locator, page, or position signal', async () => {
const { importer, repo, importRepo } = makeImporter();
const planned = {
plan: {
userMappings: [{ sourceUserId: 'u1', targetUserId: 10 }],
pathMappings: [],
},
execution: {
matchedBooks: [{ sourceBookId: 'b-source', targetBookId: 200 }],
sourceData: {
availableDomains: {
userBookStatuses: false,
readingProgress: true,
bookmarks: false,
annotations: false,
shelves: false,
},
books: [{ sourceBookId: 'b-source', files: [] }],
userBookStatuses: [],
userFileProgress: [
{
sourceUserId: 'u1',
sourceBookId: 'b-source',
sourceFileId: null,
percentage: 0,
cfi: null,
href: null,
pageNumber: 0,
positionSeconds: 0,
updatedAt: null,
},
],
bookmarks: [],
annotations: [],
shelves: [],
shelfBooks: [],
},
},
};
await importer.import(304, planned as never, vi.fn().mockResolvedValue(undefined));
expect(importRepo.batchUpsertReadingProgress).toHaveBeenCalledWith([]);
expect(importRepo.batchUpsertAudiobookProgress).toHaveBeenCalledWith([]);
expect(repo.setRunMetric).toHaveBeenCalledWith(304, 'user_state', 'reading_progress', expect.objectContaining({ processed: 1, skipped: 1 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(304, 'user_state', 'audiobook_progress', expect.objectContaining({ processed: 1, skipped: 1 }));
});
it('keeps zero-percent progress rows when a locator exists', async () => {
const { importer, importRepo } = makeImporter();
importRepo.fetchTargetBookPrimaryFiles.mockResolvedValue({
primaryFilesByBookId: new Map([[200, 500]]),
audiobookPrimaryFilesByBookId: new Map(),
});
const planned = {
plan: {
userMappings: [{ sourceUserId: 'u1', targetUserId: 10 }],
pathMappings: [],
},
execution: {
matchedBooks: [{ sourceBookId: 'b-source', targetBookId: 200 }],
sourceData: {
availableDomains: {
userBookStatuses: false,
readingProgress: true,
bookmarks: false,
annotations: false,
shelves: false,
},
books: [{ sourceBookId: 'b-source', files: [] }],
userBookStatuses: [],
userFileProgress: [
{
sourceUserId: 'u1',
sourceBookId: 'b-source',
sourceFileId: null,
percentage: 0,
cfi: '/6/2',
href: null,
pageNumber: 0,
positionSeconds: 0,
updatedAt: null,
},
],
bookmarks: [],
annotations: [],
shelves: [],
shelfBooks: [],
},
},
};
await importer.import(305, planned as never, vi.fn().mockResolvedValue(undefined));
expect(importRepo.batchUpsertReadingProgress).toHaveBeenCalledWith([
expect.objectContaining({
userId: 10,
bookFileId: 500,
percentage: 0,
cfi: '/6/2',
pageNumber: 0,
}),
]);
});
}); });
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { ReadStatus, ReadStatusSource } from '@bookorbit/types'; import type { ReadStatus, ReadStatusSource } from '@bookorbit/types';
import type { SourceBook, SourceBookmark } from '../adapters/source-adapter.types'; import type { SourceBook, SourceBookmark, SourceUserFileProgress } from '../adapters/source-adapter.types';
import { MigrationRepository } from '../migration.repository'; import { MigrationRepository } from '../migration.repository';
import { MigrationImportRepository } from './migration-import.repository'; import { MigrationImportRepository } from './migration-import.repository';
import type { PlannerResult } from '../planner/planner.types'; import type { PlannerResult } from '../planner/planner.types';
@@ -22,6 +22,13 @@ function isDomainAvailable(planned: PlannerResult, domain: keyof NonNullable<Pla
return planned.execution.sourceData.availableDomains?.[domain] ?? true; return planned.execution.sourceData.availableDomains?.[domain] ?? true;
} }
function hasMeaningfulProgressSignal(row: SourceUserFileProgress, percentage: number, positionSeconds: number | null): boolean {
const hasLocator = (row.cfi?.trim().length ?? 0) > 0 || (row.href?.trim().length ?? 0) > 0;
const hasPageNumber = typeof row.pageNumber === 'number' && Number.isFinite(row.pageNumber) && row.pageNumber > 0;
const hasPosition = positionSeconds != null && positionSeconds > 0;
return percentage > 0 || hasLocator || hasPageNumber || hasPosition;
}
@Injectable() @Injectable()
export class UserStateImporter { export class UserStateImporter {
constructor( constructor(
@@ -156,13 +163,24 @@ export class UserStateImporter {
continue; continue;
} }
const percentage = clampPercent(row.percentage);
const positionSeconds = row.positionSeconds == null ? null : clampNonNegative(row.positionSeconds);
if (!hasMeaningfulProgressSignal(row, percentage, positionSeconds)) {
counters.skipped += 1;
continue;
}
const pageNumber =
typeof row.pageNumber === 'number' && Number.isFinite(row.pageNumber) && row.pageNumber >= 0 ? Math.trunc(row.pageNumber) : null;
const cfi = row.cfi?.trim() ? row.cfi : null;
batch.push({ batch.push({
bookFileId: targetFileId, bookFileId: targetFileId,
userId: targetUserId, userId: targetUserId,
percentage: clampPercent(row.percentage), percentage,
cfi: row.cfi, cfi,
pageNumber: row.pageNumber, pageNumber,
positionSeconds: row.positionSeconds, positionSeconds,
updatedAt: toDate(row.updatedAt) ?? new Date(), updatedAt: toDate(row.updatedAt) ?? new Date(),
}); });
counters.imported += 1; counters.imported += 1;
@@ -222,12 +240,19 @@ export class UserStateImporter {
continue; continue;
} }
const percentage = clampPercent(row.percentage);
const positionSeconds = clampNonNegative(row.positionSeconds) ?? 0;
if (!hasMeaningfulProgressSignal(row, percentage, positionSeconds)) {
counters.skipped += 1;
continue;
}
batch.push({ batch.push({
userId: targetUserId, userId: targetUserId,
bookId: targetBookId, bookId: targetBookId,
percentage: clampPercent(row.percentage), percentage,
currentFileId: targetFileId, currentFileId: targetFileId,
positionSeconds: clampNonNegative(row.positionSeconds) ?? 0, positionSeconds,
updatedAt: toDate(row.updatedAt) ?? new Date(), updatedAt: toDate(row.updatedAt) ?? new Date(),
}); });
counters.imported += 1; counters.imported += 1;