mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -187,4 +187,128 @@ describe('DetailsTab — present state', () => {
|
||||
expect(wrapper.text()).toContain('Favorites')
|
||||
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">
|
||||
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
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 { bookCoverStyle } from '@/features/book/lib/book-cover'
|
||||
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 koboState = ref<BookKoboState | null>(null)
|
||||
const supplementalLoading = ref(false)
|
||||
const resettingFileIds = ref<number[]>([])
|
||||
const providerIconErrors = ref<Record<string, boolean>>({})
|
||||
|
||||
const providerLinks = computed<ProviderLink[]>(() => {
|
||||
@@ -441,8 +456,8 @@ type ProgressRow = {
|
||||
percentage: number
|
||||
color: string
|
||||
badgeStyle: Record<string, string>
|
||||
tooltipText: string
|
||||
finished: boolean
|
||||
resetFileId: number | null
|
||||
}
|
||||
|
||||
const KOBO_COLOR = '#f59e0b'
|
||||
@@ -457,8 +472,8 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
|
||||
percentage: progress.percentage,
|
||||
color,
|
||||
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
|
||||
tooltipText: file.absolutePath,
|
||||
finished: progress.percentage >= 100,
|
||||
resetFileId: file.id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -471,20 +486,19 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
|
||||
percentage: audiobookProgress.value.percentage,
|
||||
color,
|
||||
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
|
||||
tooltipText: audioFile?.absolutePath ?? '',
|
||||
finished: audiobookProgress.value.percentage >= 100,
|
||||
resetFileId: audiobookProgress.value.currentFileId,
|
||||
})
|
||||
}
|
||||
const koboPercent = koboState.value?.readingState?.progressPercent
|
||||
if (canViewKobo.value && koboPercent != null && koboPercent > 0) {
|
||||
const syncCols = koboState.value?.syncCollections ?? []
|
||||
rows.push({
|
||||
label: 'Kobo',
|
||||
percentage: koboPercent,
|
||||
color: KOBO_COLOR,
|
||||
badgeStyle: { color: KOBO_COLOR, borderColor: `${KOBO_COLOR}66`, backgroundColor: `${KOBO_COLOR}1a` },
|
||||
tooltipText: syncCols.length > 0 ? `Via: ${syncCols.join(', ')}` : 'Kobo device',
|
||||
finished: koboPercent >= 100,
|
||||
resetFileId: null,
|
||||
})
|
||||
}
|
||||
if (canViewKoreader.value && koreaderBookProgress.value != null && koreaderBookProgress.value.canonicalPercentage > 0) {
|
||||
@@ -494,8 +508,8 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
|
||||
percentage: koreaderBookProgress.value.canonicalPercentage,
|
||||
color: koreaderColor,
|
||||
badgeStyle: { color: koreaderColor, borderColor: `${koreaderColor}66`, backgroundColor: `${koreaderColor}1a` },
|
||||
tooltipText: 'KOReader device sync',
|
||||
finished: koreaderBookProgress.value.canonicalPercentage >= 100,
|
||||
resetFileId: null,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
@@ -525,7 +539,10 @@ function formatDateTime(iso: string): 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 {
|
||||
@@ -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
|
||||
|
||||
async function loadSupplemental() {
|
||||
@@ -1057,9 +1102,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="leftColumnProgressVisible.length" class="mt-4 space-y-2">
|
||||
<Tooltip v-for="row in leftColumnProgressVisible" :key="row.label">
|
||||
<TooltipTrigger as-child>
|
||||
<div class="flex items-center gap-2 cursor-default">
|
||||
<div v-for="row in leftColumnProgressVisible" :key="row.label" class="flex items-center gap-2 cursor-default">
|
||||
<span
|
||||
class="w-11 shrink-0 text-center text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border"
|
||||
:style="row.badgeStyle"
|
||||
@@ -1077,10 +1120,20 @@ watch(
|
||||
</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>
|
||||
<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)"
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ row.tooltipText }}</TooltipContent>
|
||||
<TooltipContent>{{ isResettingFile(row.resetFileId) ? 'Resetting...' : 'Reset file progress' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p>
|
||||
</div>
|
||||
<div v-if="koboAnomaly" class="mt-2 flex items-center gap-1.5">
|
||||
@@ -1350,9 +1403,7 @@ watch(
|
||||
<!-- 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" class="space-y-2">
|
||||
<Tooltip v-for="row in leftColumnProgressVisible" :key="row.label">
|
||||
<TooltipTrigger as-child>
|
||||
<div class="flex items-center gap-2 cursor-default">
|
||||
<div v-for="row in leftColumnProgressVisible" :key="row.label" class="flex items-center gap-2 cursor-default">
|
||||
<span
|
||||
class="w-11 shrink-0 text-center text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border"
|
||||
:style="row.badgeStyle"
|
||||
@@ -1370,10 +1421,20 @@ watch(
|
||||
</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>
|
||||
<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)"
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ row.tooltipText }}</TooltipContent>
|
||||
<TooltipContent>{{ isResettingFile(row.resetFileId) ? 'Resetting...' : 'Reset file progress' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p>
|
||||
</div>
|
||||
<div v-if="koboAnomaly" class="flex items-center gap-1.5">
|
||||
|
||||
@@ -3,6 +3,7 @@ module.exports = {
|
||||
rules: {
|
||||
"type-enum": [2, "always", ["feat", "fix", "db", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "security", "revert"]],
|
||||
"header-max-length": [2, "always", 100],
|
||||
"body-max-line-length": [2, "always", 250],
|
||||
"subject-full-stop": [2, "never", "."],
|
||||
"scope-enum": [0],
|
||||
"scope-empty": [0],
|
||||
|
||||
@@ -132,6 +132,7 @@ function makeController() {
|
||||
getBookProgress: vi.fn(),
|
||||
getAudioProgress: vi.fn(),
|
||||
saveProgress: vi.fn(),
|
||||
clearFileProgress: vi.fn(),
|
||||
saveAudioProgress: vi.fn(),
|
||||
updateMetadata: vi.fn(),
|
||||
updateMetadataLocks: vi.fn(),
|
||||
@@ -742,9 +743,11 @@ describe('BookController', () => {
|
||||
const user = makeUser();
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -478,6 +478,12 @@ export class BookController {
|
||||
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')
|
||||
async getAudioProgress(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
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) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
|
||||
@@ -98,6 +98,7 @@ function makeService() {
|
||||
findProgress: vi.fn(),
|
||||
findProgressByBook: vi.fn(),
|
||||
upsertProgress: vi.fn(),
|
||||
clearFileProgress: vi.fn(),
|
||||
findAudioProgress: vi.fn(),
|
||||
upsertAudioProgress: 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', () => {
|
||||
it('writes audio progress when current file belongs to the target book', async () => {
|
||||
const { service, bookRepo, libraryService } = makeService();
|
||||
|
||||
@@ -1300,6 +1300,11 @@ export class BookService {
|
||||
.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> {
|
||||
await this.verifyBookAccess(bookId, user);
|
||||
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 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 { MigrationImportRepository } from './migration-import.repository';
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
export class UserStateImporter {
|
||||
constructor(
|
||||
@@ -156,13 +163,24 @@ export class UserStateImporter {
|
||||
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({
|
||||
bookFileId: targetFileId,
|
||||
userId: targetUserId,
|
||||
percentage: clampPercent(row.percentage),
|
||||
cfi: row.cfi,
|
||||
pageNumber: row.pageNumber,
|
||||
positionSeconds: row.positionSeconds,
|
||||
percentage,
|
||||
cfi,
|
||||
pageNumber,
|
||||
positionSeconds,
|
||||
updatedAt: toDate(row.updatedAt) ?? new Date(),
|
||||
});
|
||||
counters.imported += 1;
|
||||
@@ -222,12 +240,19 @@ export class UserStateImporter {
|
||||
continue;
|
||||
}
|
||||
|
||||
const percentage = clampPercent(row.percentage);
|
||||
const positionSeconds = clampNonNegative(row.positionSeconds) ?? 0;
|
||||
if (!hasMeaningfulProgressSignal(row, percentage, positionSeconds)) {
|
||||
counters.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.push({
|
||||
userId: targetUserId,
|
||||
bookId: targetBookId,
|
||||
percentage: clampPercent(row.percentage),
|
||||
percentage,
|
||||
currentFileId: targetFileId,
|
||||
positionSeconds: clampNonNegative(row.positionSeconds) ?? 0,
|
||||
positionSeconds,
|
||||
updatedAt: toDate(row.updatedAt) ?? new Date(),
|
||||
});
|
||||
counters.imported += 1;
|
||||
|
||||
Reference in New Issue
Block a user