mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user