fix: align Book Dock ready filter with filing readiness

Use the destination-complete predicate for the Ready chip and select-all operations so counts and filtered results remain consistent.

Fixes #1215
This commit is contained in:
neon
2026-08-30 17:21:35 +00:00
parent 9391555859
commit cc1b767264
22 changed files with 301 additions and 33 deletions
@@ -8,7 +8,15 @@ import { api } from '@/lib/api'
const { t } = useI18n()
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionPayload: {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
status?: string
needsReview?: boolean
readyToFile?: boolean
search?: string
}
selectionCount: number
}>()
@@ -11,6 +11,7 @@ const props = defineProps<{
excludedIds?: number[]
status?: string
needsReview?: boolean
readyToFile?: boolean
search?: string
}
selectionCount: number
@@ -22,12 +22,12 @@ const searchQuery = ref('')
let searchTimer: ReturnType<typeof setTimeout> | null = null
/** Every chip maps to something the files endpoint can answer, so counts cover the whole dock. */
const chips = computed<{ view: BookDockView; label: string; count: number }[]>(() => [
{ view: 'all', label: t('bookDock.layout.filter.all'), count: props.summary.total },
{ view: 'needsReview', label: t('bookDock.layout.filter.needsReview'), count: props.summary.needsReview },
{ view: 'pending', label: t('bookDock.layout.filter.pending'), count: props.summary.pending },
{ view: 'ready', label: t('bookDock.layout.filter.ready'), count: props.summary.ready },
{ view: 'error', label: t('bookDock.layout.filter.error'), count: props.summary.error },
const chips = computed<{ view: BookDockView; testId: string; label: string; count: number }[]>(() => [
{ view: 'all', testId: 'all', label: t('bookDock.layout.filter.all'), count: props.summary.total },
{ view: 'needsReview', testId: 'needsReview', label: t('bookDock.layout.filter.needsReview'), count: props.summary.needsReview },
{ view: 'pending', testId: 'pending', label: t('bookDock.layout.filter.pending'), count: props.summary.pending },
{ view: 'readyToFile', testId: 'ready', label: t('bookDock.layout.filter.ready'), count: props.summary.readyToFile },
{ view: 'error', testId: 'error', label: t('bookDock.layout.filter.error'), count: props.summary.error },
])
function selectView(view: BookDockView) {
@@ -53,7 +53,7 @@ function clearSearch() {
v-for="chip in chips"
:key="chip.view"
type="button"
:data-testid="`book-dock-chip-${chip.view}`"
:data-testid="`book-dock-chip-${chip.testId}`"
:aria-pressed="props.activeView === chip.view"
:disabled="chip.count === 0 && props.activeView !== chip.view"
class="flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-default disabled:opacity-45"
@@ -12,7 +12,15 @@ import { useBookDockFinalize } from '../composables/useBookDockFinalize'
const { t } = useI18n()
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionPayload: {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
status?: string
needsReview?: boolean
readyToFile?: boolean
search?: string
}
selectionCount: number
}>()
@@ -21,6 +29,8 @@ type FinalizePayload = {
selectAll?: boolean
excludedIds?: number[]
status?: string
needsReview?: boolean
readyToFile?: boolean
search?: string
defaultLibraryId?: number
defaultFolderId?: number
@@ -8,7 +8,15 @@ import { useLibraries } from '@/features/library/composables/useLibraries'
const { t } = useI18n()
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionPayload: {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
status?: string
needsReview?: boolean
readyToFile?: boolean
search?: string
}
selectionCount: number
}>()
@@ -0,0 +1,32 @@
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { BookDockSummary } from '@bookorbit/types'
import BookDockFilterBar from '../BookDockFilterBar.vue'
const summary: BookDockSummary = {
pending: 0,
working: 0,
ready: 168,
error: 0,
needsReview: 167,
readyToFile: 1,
total: 168,
paused: false,
}
describe('BookDockFilterBar', () => {
it('shows filing-ready files in the Ready chip and selects the matching predicate', async () => {
const wrapper = shallowMount(BookDockFilterBar, {
props: { activeView: 'all', summary },
})
const readyChip = wrapper.get('[data-testid="book-dock-chip-ready"]')
expect(readyChip.text()).toContain('Ready')
expect(readyChip.text()).toContain('1')
expect(readyChip.text()).not.toContain('168')
await readyChip.trigger('click')
expect(wrapper.emitted('view')).toEqual([['readyToFile']])
})
})
@@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBookDockFiles } from '../useBookDockFiles'
const apiMock = vi.fn<(...args: unknown[]) => Promise<Response>>()
vi.mock('@/lib/api', () => ({
api: (...args: unknown[]) => apiMock(...args),
}))
beforeEach(() => {
apiMock.mockReset()
})
describe('useBookDockFiles deleted selections', () => {
it('removes a deleted file from an explicit selection', () => {
const files = useBookDockFiles()
@@ -30,3 +40,27 @@ describe('useBookDockFiles deleted selections', () => {
expect(files.isSelected(3)).toBe(true)
})
})
describe('useBookDockFiles ready-to-file filter', () => {
it('uses the ready-to-file query and preserves it for select-all actions', async () => {
apiMock.mockResolvedValue({
ok: true,
json: vi
.fn<() => Promise<{ items: []; total: number; page: number; size: number }>>()
.mockResolvedValue({ items: [], total: 1, page: 1, size: 20 }),
} as unknown as Response)
const files = useBookDockFiles()
files.setView('readyToFile')
await vi.waitFor(() => expect(apiMock).toHaveBeenCalledOnce())
const requestUrl = String(apiMock.mock.calls[0]?.[0])
expect(requestUrl).toContain('readyToFile=true')
expect(requestUrl).not.toContain('status=ready')
expect(files.activeView.value).toBe('readyToFile')
files.toggleSelectAll()
expect(files.getSelectionPayload()).toEqual({ selectAll: true, excludedIds: [], readyToFile: true })
})
})
@@ -5,8 +5,8 @@ import type { BookDockFile, BookDockFilesPage, BookDockFileStatus } from '@booko
export type SortField = 'createdAt' | 'fileName' | 'format' | 'status' | 'fileSize' | 'attention'
export type SortOrder = 'asc' | 'desc'
/** The chip set above the list. `needsReview` is a server-side predicate, not a status. */
export type BookDockView = 'all' | 'needsReview' | BookDockFileStatus
/** The chip set above the list. Review and filing readiness are predicates, not processing statuses. */
export type BookDockView = 'all' | 'needsReview' | 'readyToFile' | BookDockFileStatus
export function useBookDockFiles() {
const items = ref<BookDockFile[]>([])
@@ -17,6 +17,7 @@ export function useBookDockFiles() {
const filters = reactive({
status: undefined as BookDockFileStatus | undefined,
needsReview: false,
readyToFile: false,
search: '',
page: 1,
limit: 20,
@@ -39,6 +40,7 @@ export function useBookDockFiles() {
const params = new URLSearchParams()
if (filters.status) params.set('status', filters.status)
if (filters.needsReview) params.set('needsReview', 'true')
if (filters.readyToFile) params.set('readyToFile', 'true')
if (filters.search) params.set('search', filters.search)
params.set('page', String(filters.page))
params.set('limit', String(filters.limit))
@@ -66,19 +68,22 @@ export function useBookDockFiles() {
clearSelection()
filters.status = status
filters.needsReview = false
filters.readyToFile = false
filters.page = 1
fetchFiles()
}
const activeView = computed<BookDockView>(() => {
if (filters.needsReview) return 'needsReview'
if (filters.readyToFile) return 'readyToFile'
return filters.status ?? 'all'
})
function setView(view: BookDockView) {
clearSelection()
filters.needsReview = view === 'needsReview'
filters.status = view === 'all' || view === 'needsReview' ? undefined : view
filters.readyToFile = view === 'readyToFile'
filters.status = view === 'all' || view === 'needsReview' || view === 'readyToFile' ? undefined : view
filters.page = 1
fetchFiles()
}
@@ -179,6 +184,7 @@ export function useBookDockFiles() {
excludedIds?: number[]
status?: BookDockFileStatus
needsReview?: boolean
readyToFile?: boolean
search?: string
} {
if (selectAll.value) {
@@ -187,6 +193,7 @@ export function useBookDockFiles() {
excludedIds: [...excludedIds.value],
...(filters.status ? { status: filters.status } : {}),
...(filters.needsReview ? { needsReview: true } : {}),
...(filters.readyToFile ? { readyToFile: true } : {}),
...(filters.search ? { search: filters.search } : {}),
}
}
@@ -12,6 +12,8 @@ export function useBookDockFinalize() {
selectAll?: boolean
excludedIds?: number[]
status?: string
needsReview?: boolean
readyToFile?: boolean
search?: string
defaultLibraryId?: number
defaultFolderId?: number
+1
View File
@@ -5673,6 +5673,7 @@
},
"empty": {
"nothingToReview": "Nothing needs review. Every ready file has a destination and a confident match.",
"nothingReadyToFile": "No files are ready to file.",
"dropHint": "Or drop files anywhere on this page."
},
"selectAllOnPage": "Select all files on this page",
+2
View File
@@ -36,6 +36,7 @@ type BookDockSelectionPayload = {
excludedIds?: number[]
status?: string
needsReview?: boolean
readyToFile?: boolean
search?: string
}
@@ -156,6 +157,7 @@ const errorCount = computed(() => {
const emptyMessage = computed(() => {
if (filters.search) return t('views.bookDock.empty.noSearchMatch', { query: filters.search })
if (filters.needsReview) return t('bookDock.layout.empty.nothingToReview')
if (filters.readyToFile) return t('bookDock.layout.empty.nothingReadyToFile')
if (filters.status) return t('views.bookDock.empty.noStatusFiles', { status: filters.status })
return t('views.bookDock.empty.uploadPrompt')
})
@@ -473,9 +473,10 @@ describe('BookDockFinalizeService', () => {
);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
const result = await service.finalize(7, true, true, [], true, [], 5, 9, [], 'ready', 'foo');
const result = await service.finalize(7, true, true, [], true, [], 5, 9, [], undefined, 'foo', undefined, true);
expect(repo.findSelectionBatch).toHaveBeenCalledTimes(2);
expect(repo.findSelectionBatch).toHaveBeenCalledWith(expect.objectContaining({ readyToFile: true }));
expect(result.total).toBe(2);
expect(result.failed).toBe(0);
});
@@ -184,6 +184,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status?: string,
search?: string,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<BookDockFinalizeResult> {
const ids = selectAll ? [] : dedupeIds(fileIds ?? []);
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
@@ -202,6 +203,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
});
@@ -338,6 +340,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status?: string,
search?: string,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<BookDockFinalizePreviewResult> {
const summary = createFinalizePreviewSummary();
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
@@ -351,6 +354,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status,
search,
needsReview,
readyToFile,
async (rows, missingIds) => {
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
for (const candidate of prepared.analyses) {
@@ -384,6 +388,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status?: string,
search?: string,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<BookDockDiscardDuplicatesResult> {
const startedAt = Date.now();
this.logger.log(`[book_dock.discard_duplicates] [start] userId=${userId} selectAll=${selectAll === true} - duplicate discard started`);
@@ -403,6 +408,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status,
search,
needsReview,
readyToFile,
async (rows, missingIds) => {
total += rows.length + missingIds.length;
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
@@ -587,6 +593,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status: string | undefined,
search: string | undefined,
needsReview: boolean | undefined,
readyToFile: boolean | undefined,
processBatch: (rows: BookDockFileRow[], missingIds: number[]) => Promise<void>,
): Promise<void> {
if (selectAll) {
@@ -599,6 +606,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
});
@@ -721,8 +729,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status?: string,
search?: string,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<{ fileId: number; fileName: string; newName: string }[]> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, canManageAll, needsReview) : (fileIds ?? []);
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, canManageAll, needsReview, readyToFile) : (fileIds ?? []);
if (!ids.length) return [];
const rows = await this.repo.findByIds(ids, userId, canManageAll);
@@ -67,14 +67,16 @@ describe('BookDockController', () => {
vi.clearAllMocks();
});
it('listFiles applies defaults before delegating', async () => {
it('listFiles applies defaults and propagates the ready-to-file predicate', async () => {
const { controller, service } = makeController();
service.listFiles.mockResolvedValue({ items: [], total: 0, page: 1, size: 20 });
await controller.listFiles(MOCK_USER, {});
await controller.listFiles(MOCK_USER, { readyToFile: true });
expect(service.listFiles).toHaveBeenCalledWith({
status: undefined,
needsReview: undefined,
readyToFile: true,
page: 1,
limit: 20,
sort: 'createdAt',
@@ -143,9 +145,9 @@ describe('BookDockController', () => {
it('bulk and finalize endpoints delegate payload fields as expected', async () => {
const { controller, service, finalizeService, watcherService } = makeController();
await controller.bulkDiscard(MOCK_USER, { fileIds: [1], selectAll: false, excludedIds: [2], status: 'error', search: 'x' });
await controller.applyFetched(MOCK_USER, { fileIds: [1], selectAll: true, excludedIds: [2], status: 'ready', search: 'x' });
await controller.retryFetch(MOCK_USER, { fileIds: [3], selectAll: false, excludedIds: [4], status: 'error', search: 'y' });
await controller.bulkDiscard(MOCK_USER, { fileIds: [1], selectAll: false, excludedIds: [2], status: 'error', search: 'x', readyToFile: true });
await controller.applyFetched(MOCK_USER, { fileIds: [1], selectAll: true, excludedIds: [2], status: 'ready', search: 'x', readyToFile: true });
await controller.retryFetch(MOCK_USER, { fileIds: [3], selectAll: false, excludedIds: [4], status: 'error', search: 'y', readyToFile: true });
await controller.refetchMetadata(MOCK_USER, 11);
await controller.setTarget(MOCK_USER, {
fileIds: [5],
@@ -153,8 +155,9 @@ describe('BookDockController', () => {
excludedIds: [6],
targetLibraryId: undefined,
targetFolderId: undefined,
readyToFile: true,
});
await controller.selectionSummary(MOCK_USER, { fileIds: [7], selectAll: false, excludedIds: [8] });
await controller.selectionSummary(MOCK_USER, { fileIds: [7], selectAll: false, excludedIds: [8], readyToFile: true });
await controller.bulkEdit(MOCK_USER, {
fileIds: [9],
selectAll: false,
@@ -162,8 +165,9 @@ describe('BookDockController', () => {
fields: { title: 'Edited' },
enabledFields: ['title'],
mergeArrays: false,
readyToFile: true,
} as any);
await controller.previewNames(MOCK_USER, { fileIds: [10], selectAll: false, excludedIds: [], defaultLibraryId: 2 } as any);
await controller.previewNames(MOCK_USER, { fileIds: [10], selectAll: false, excludedIds: [], defaultLibraryId: 2, readyToFile: true } as any);
await controller.previewFinalize(MOCK_USER, {
fileIds: [10],
selectAll: false,
@@ -171,6 +175,7 @@ describe('BookDockController', () => {
defaultLibraryId: 2,
defaultFolderId: 3,
overrides: [],
readyToFile: true,
} as any);
await controller.discardFinalizeDuplicates(MOCK_USER, {
fileIds: [10],
@@ -179,19 +184,38 @@ describe('BookDockController', () => {
defaultLibraryId: 2,
defaultFolderId: 3,
overrides: [],
readyToFile: true,
} as any);
await controller.finalize(
{ id: 99, isSuperuser: true, permissions: [] } as any,
{ fileIds: [1], defaultLibraryId: 2, defaultFolderId: 3, selectAll: false, excludedIds: [], overrides: [] } as any,
{ fileIds: [1], defaultLibraryId: 2, defaultFolderId: 3, selectAll: false, excludedIds: [], overrides: [], readyToFile: true } as any,
);
await controller.rescan();
await controller.pause();
await controller.resume();
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false, undefined);
expect(service.bulkDiscard).toHaveBeenCalledWith([1], false, [2], 'error', 'x', MOCK_USER.id, false, undefined, true);
expect(service.bulkApplyFetched).toHaveBeenCalledWith([1], true, [2], 'ready', 'x', MOCK_USER.id, false, undefined, true);
expect(service.bulkRetryFetch).toHaveBeenCalledWith([3], false, [4], 'error', 'y', MOCK_USER.id, false, undefined, true);
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false, undefined, true);
expect(service.selectionSummary).toHaveBeenCalledWith([7], false, [8], undefined, undefined, MOCK_USER.id, false, undefined, true);
expect(service.bulkEdit).toHaveBeenCalledWith(
[9],
false,
[],
{ title: 'Edited' },
['title'],
false,
undefined,
undefined,
MOCK_USER.id,
false,
undefined,
true,
);
expect(service.refetchMetadata).toHaveBeenCalledWith(11, MOCK_USER.id, false);
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined, undefined);
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined, undefined);
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined, undefined, true);
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined, undefined, true);
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(
1,
false,
@@ -205,8 +229,9 @@ describe('BookDockController', () => {
undefined,
undefined,
undefined,
true,
);
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, true, [1], false, [], 2, 3, [], undefined, undefined, undefined);
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, true, [1], false, [], 2, 3, [], undefined, undefined, undefined, true);
expect(watcherService.rescan).toHaveBeenCalled();
expect(service.pauseProcessing).toHaveBeenCalledTimes(1);
expect(service.resumeProcessing).toHaveBeenCalledTimes(1);
@@ -66,6 +66,7 @@ export class BookDockController {
return this.service.listFiles({
status: query.status,
needsReview: query.needsReview,
readyToFile: query.readyToFile,
page: query.page ?? 1,
limit: query.limit ?? 20,
sort: query.sort ?? 'createdAt',
@@ -172,6 +173,7 @@ export class BookDockController {
user.id,
this.canManageAll(user),
dto.needsReview,
dto.readyToFile,
);
}
@@ -186,6 +188,7 @@ export class BookDockController {
user.id,
this.canManageAll(user),
dto.needsReview,
dto.readyToFile,
);
}
@@ -200,6 +203,7 @@ export class BookDockController {
user.id,
this.canManageAll(user),
dto.needsReview,
dto.readyToFile,
);
}
@@ -222,6 +226,7 @@ export class BookDockController {
user.id,
this.canManageAll(user),
dto.needsReview,
dto.readyToFile,
);
}
@@ -236,6 +241,7 @@ export class BookDockController {
user.id,
this.canManageAll(user),
dto.needsReview,
dto.readyToFile,
);
}
@@ -254,6 +260,7 @@ export class BookDockController {
user.id,
this.canManageAll(user),
dto.needsReview,
dto.readyToFile,
);
}
@@ -269,6 +276,7 @@ export class BookDockController {
dto.status,
dto.search,
dto.needsReview,
dto.readyToFile,
);
}
@@ -289,6 +297,7 @@ export class BookDockController {
dto.status,
dto.search,
dto.needsReview,
dto.readyToFile,
);
}
@@ -309,6 +318,7 @@ export class BookDockController {
dto.status,
dto.search,
dto.needsReview,
dto.readyToFile,
);
}
@@ -339,6 +349,7 @@ export class BookDockController {
dto.status,
dto.search,
dto.needsReview,
dto.readyToFile,
);
}
@@ -21,10 +21,12 @@ describe('BookDock DTO validation', () => {
sort: 'fileName',
order: 'asc',
search: 'dune',
readyToFile: '1',
});
expect(dto.page).toBe(2);
expect(dto.limit).toBe(30);
expect(dto.readyToFile).toBe(true);
expect((await validate(dto)).length).toBe(0);
expect((await errorsFor(ListBookDockFilesDto, { status: 'unknown' })).length).toBeGreaterThan(0);
expect((await errorsFor(ListBookDockFilesDto, { limit: 200 })).length).toBeGreaterThan(0);
@@ -115,6 +117,7 @@ describe('BookDock DTO validation', () => {
selectAll: true,
status: 'error',
search: 'abc',
readyToFile: true,
targetLibraryId: null,
targetFolderId: null,
})
@@ -78,6 +78,20 @@ function makeDb() {
};
}
function hasReadyToFileCondition(value: unknown): boolean {
if (!value || typeof value !== 'object') return false;
const node = value as { op?: string; clauses?: unknown[]; right?: unknown };
const clauses = node.clauses ?? [];
if (
node.op === 'and' &&
clauses.some((clause) => (clause as { op?: string; right?: unknown }).op === 'eq' && (clause as { right?: unknown }).right === 'ready') &&
clauses.filter((clause) => (clause as { op?: string }).op === 'isNotNull').length === 2
) {
return true;
}
return clauses.some(hasReadyToFileCondition);
}
describe('BookDockRepository', () => {
it('findExistingBooksByAbsolutePaths short-circuits an empty path list', async () => {
const { db } = makeDb();
@@ -173,6 +187,36 @@ describe('BookDockRepository', () => {
expect(selectBuilder.limit).toHaveBeenCalledWith(2);
});
it('uses the same ready-to-file predicate for paged results and select-all batches', async () => {
const paged = makeDb();
paged.selectBuilder.where.mockReturnValueOnce(paged.selectBuilder).mockResolvedValueOnce([{ total: 1 }]);
paged.selectBuilder.offset.mockResolvedValueOnce([{ id: 1 }]);
const pagedRepo = new BookDockRepository(paged.db as never);
await pagedRepo.findAll({
readyToFile: true,
page: 1,
limit: 20,
sort: 'createdAt',
order: 'desc',
userId: 1,
canManageAll: true,
});
expect(paged.selectBuilder.where.mock.calls).toHaveLength(2);
for (const [where] of paged.selectBuilder.where.mock.calls) {
expect(hasReadyToFileCondition(where)).toBe(true);
}
const batched = makeDb();
batched.selectBuilder.limit.mockResolvedValueOnce([{ id: 1 }]);
const batchedRepo = new BookDockRepository(batched.db as never);
await batchedRepo.findSelectionBatch({ limit: 20, readyToFile: true, userId: 1, canManageAll: true });
expect(hasReadyToFileCondition(batched.selectBuilder.where.mock.calls[0]?.[0])).toBe(true);
});
it('findAll returns paged rows and scalar total using status/search filters', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.where.mockReturnValueOnce(selectBuilder).mockResolvedValueOnce([{ total: 7 }]);
@@ -40,6 +40,7 @@ function needsReviewCondition(): SQL {
export interface ListOptions {
status?: string;
needsReview?: boolean;
readyToFile?: boolean;
page: number;
limit: number;
sort: string;
@@ -55,6 +56,7 @@ export interface SelectionBatchOptions {
excludedIds?: number[];
status?: string;
needsReview?: boolean;
readyToFile?: boolean;
search?: string;
userId: number;
canManageAll: boolean;
@@ -65,7 +67,7 @@ export class BookDockRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findAll(opts: ListOptions): Promise<{ items: BookDockFileRow[]; total: number }> {
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.canManageAll, opts.needsReview);
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.canManageAll, opts.needsReview, opts.readyToFile);
const where = conditions.length ? and(...conditions) : undefined;
@@ -143,8 +145,9 @@ export class BookDockRepository {
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search, userId, canManageAll ?? true, needsReview);
const conditions = this.buildSelectionConditions(status, search, userId, canManageAll ?? true, needsReview, readyToFile);
if (excludedIds?.length) conditions.push(notInArray(bookDockFiles.id, excludedIds));
const where = conditions.length ? and(...conditions) : undefined;
const rows = await this.db.select({ id: bookDockFiles.id }).from(bookDockFiles).where(where);
@@ -179,7 +182,14 @@ export class BookDockRepository {
}
async findSelectionBatch(options: SelectionBatchOptions): Promise<BookDockFileRow[]> {
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.canManageAll, options.needsReview);
const conditions = this.buildSelectionConditions(
options.status,
options.search,
options.userId,
options.canManageAll,
options.needsReview,
options.readyToFile,
);
if (options.excludedIds?.length) conditions.push(notInArray(bookDockFiles.id, options.excludedIds));
if (options.afterId !== undefined) conditions.push(gt(bookDockFiles.id, options.afterId));
const where = conditions.length ? and(...conditions) : undefined;
@@ -265,7 +275,14 @@ export class BookDockRepository {
return eq(bookDockFiles.uploadedBy, userId);
}
private buildSelectionConditions(status?: string, search?: string, userId?: number, canManageAll?: boolean, needsReview?: boolean): SQL[] {
private buildSelectionConditions(
status?: string,
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): SQL[] {
const conditions: SQL[] = [];
if (status === 'pending') {
conditions.push(inArray(bookDockFiles.status, ['pending', 'extracting', 'fetching']));
@@ -273,6 +290,7 @@ export class BookDockRepository {
conditions.push(eq(bookDockFiles.status, status));
}
if (needsReview) conditions.push(needsReviewCondition());
if (readyToFile) conditions.push(readyToFileCondition());
if (search) conditions.push(accentInsensitiveIlike(bookDockFiles.fileName, `%${search}%`));
if (userId !== undefined && !canManageAll) {
conditions.push(eq(bookDockFiles.uploadedBy, userId));
@@ -329,13 +329,14 @@ describe('BookDockService', () => {
.mockResolvedValueOnce([]);
await service.discardFile(1, 1, false);
await service.bulkDiscard([], true);
await service.bulkDiscard([], true, [], undefined, undefined, 1, true, undefined, true);
expect(vi.mocked(unlink)).toHaveBeenCalledWith('/bucket/book.epub');
expect(vi.mocked(unlink)).toHaveBeenCalledWith('/covers/1.png');
expect(vi.mocked(unlink)).toHaveBeenCalledWith('/covers/1_thumb.jpg');
expect(repo.deleteById).toHaveBeenCalledWith(1);
expect(repo.deleteByIds).toHaveBeenCalledWith([2]);
expect(repo.findSelectionBatch).toHaveBeenCalledWith(expect.objectContaining({ readyToFile: true }));
});
it('proxies summary and statistics repository queries', async () => {
@@ -93,6 +93,7 @@ export class BookDockService {
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<void> {
await this.processSelectionRows(
{
@@ -102,6 +103,7 @@ export class BookDockService {
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
},
@@ -126,6 +128,7 @@ export class BookDockService {
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
let updated = 0;
let failed = 0;
@@ -137,6 +140,7 @@ export class BookDockService {
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
},
@@ -178,6 +182,7 @@ export class BookDockService {
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<{ total: number; applied: number; skipped: number; skippedEdited: number }> {
let applied = 0;
let skipped = 0;
@@ -190,6 +195,7 @@ export class BookDockService {
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
},
@@ -224,6 +230,7 @@ export class BookDockService {
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<{ total: number; queued: number }> {
let queued = 0;
const total = await this.processSelectionRows(
@@ -234,6 +241,7 @@ export class BookDockService {
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
},
@@ -266,6 +274,7 @@ export class BookDockService {
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
await this.assertValidTarget(targetLibraryId, targetFolderId);
let updated = 0;
@@ -277,6 +286,7 @@ export class BookDockService {
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
},
@@ -301,6 +311,7 @@ export class BookDockService {
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
readyToFile?: boolean,
): Promise<{ total: number; withDestination: number; withoutDestination: number }> {
const destinationPairCounts = new Map<string, number>();
const folderIdSet = new Set<number>();
@@ -312,6 +323,7 @@ export class BookDockService {
status,
search,
needsReview,
readyToFile,
userId,
canManageAll,
},
@@ -479,6 +491,7 @@ export class BookDockService {
status?: string;
search?: string;
needsReview?: boolean;
readyToFile?: boolean;
userId?: number;
canManageAll?: boolean;
},
@@ -498,6 +511,7 @@ export class BookDockService {
status: options.status,
search: options.search,
needsReview: options.needsReview,
readyToFile: options.readyToFile,
userId,
canManageAll,
});
+32
View File
@@ -191,6 +191,10 @@ export class FinalizeBookDockDto {
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsInt()
@@ -234,6 +238,10 @@ export class BulkDiscardDto {
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
}
export class PreviewNamesDto {
@@ -263,6 +271,10 @@ export class PreviewNamesDto {
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsInt()
@@ -295,6 +307,10 @@ export class BulkRetryFetchDto {
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
}
export class BulkApplyFetchedDto {
@@ -323,6 +339,10 @@ export class BulkApplyFetchedDto {
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
}
export class BulkSetTargetDto {
@@ -352,6 +372,10 @@ export class BulkSetTargetDto {
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsInt()
@@ -389,6 +413,10 @@ export class SelectionSummaryDto {
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
}
export class BulkEditBookDockDto {
@@ -418,6 +446,10 @@ export class BulkEditBookDockDto {
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@IsBoolean()
readyToFile?: boolean;
@ValidateNested()
@IsObject()
@Type(() => BookDockMetadataFieldsDto)
@@ -15,6 +15,11 @@ export class ListBookDockFilesDto {
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true' || value === '1')
@IsBoolean()
readyToFile?: boolean;
@IsOptional()
@Transform(({ value }) => parseInt(value as string, 10))
@IsInt()