mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(scanner): rebuild the Book Dock page as a uniform-row triage list
Replace the nine-column table with one list of uniform rows that reflows through a container query, so a single component serves both desktop and phone. Surface the match score, applied state and write path that the old row buried in tertiary text. Back it with a needsReview filter and attention sort over existing indexed columns, threaded through every bulk selection path so select-all cannot target the wrong rows. Keep confidence advisory: it gates automatic finalization only. BookDockSummary gains three additive counts; ../bookorbit-ios mirrors these types by hand.
This commit is contained in:
@@ -181,6 +181,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
overrides?: Array<{ fileId: number } & FinalizeOverrideEntry>,
|
||||
status?: string,
|
||||
search?: string,
|
||||
needsReview?: boolean,
|
||||
): Promise<BookDockFinalizeResult> {
|
||||
const ids = selectAll ? [] : dedupeIds(fileIds ?? []);
|
||||
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
|
||||
@@ -198,6 +199,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
});
|
||||
@@ -331,25 +333,36 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
overrides?: Array<{ fileId: number } & FinalizeOverrideEntry>,
|
||||
status?: string,
|
||||
search?: string,
|
||||
needsReview?: boolean,
|
||||
): Promise<BookDockFinalizePreviewResult> {
|
||||
const summary = createFinalizePreviewSummary();
|
||||
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
|
||||
|
||||
await this.processFinalizeSelection(userId, canManageAll, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
|
||||
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
|
||||
for (const candidate of prepared.analyses) {
|
||||
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
|
||||
addFinalizePreviewAnalysis(summary, analysis);
|
||||
}
|
||||
for (const fileId of missingIds) {
|
||||
addFinalizePreviewItem(summary, {
|
||||
fileId,
|
||||
fileName: `book-dock-file-${fileId}`,
|
||||
status: 'error',
|
||||
message: 'Book Dock file not found',
|
||||
});
|
||||
}
|
||||
});
|
||||
await this.processFinalizeSelection(
|
||||
userId,
|
||||
canManageAll,
|
||||
fileIds,
|
||||
selectAll,
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
async (rows, missingIds) => {
|
||||
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
|
||||
for (const candidate of prepared.analyses) {
|
||||
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
|
||||
addFinalizePreviewAnalysis(summary, analysis);
|
||||
}
|
||||
for (const fileId of missingIds) {
|
||||
addFinalizePreviewItem(summary, {
|
||||
fileId,
|
||||
fileName: `book-dock-file-${fileId}`,
|
||||
status: 'error',
|
||||
message: 'Book Dock file not found',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -366,6 +379,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
overrides?: Array<{ fileId: number } & FinalizeOverrideEntry>,
|
||||
status?: string,
|
||||
search?: string,
|
||||
needsReview?: boolean,
|
||||
): Promise<BookDockDiscardDuplicatesResult> {
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[book_dock.discard_duplicates] [start] userId=${userId} selectAll=${selectAll === true} - duplicate discard started`);
|
||||
@@ -376,25 +390,35 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
const discardedFileIds: number[] = [];
|
||||
|
||||
try {
|
||||
await this.processFinalizeSelection(userId, canManageAll, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
|
||||
total += rows.length + missingIds.length;
|
||||
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
|
||||
const duplicateRows: BookDockFileRow[] = [];
|
||||
await this.processFinalizeSelection(
|
||||
userId,
|
||||
canManageAll,
|
||||
fileIds,
|
||||
selectAll,
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
async (rows, missingIds) => {
|
||||
total += rows.length + missingIds.length;
|
||||
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
|
||||
const duplicateRows: BookDockFileRow[] = [];
|
||||
|
||||
for (const candidate of prepared.analyses) {
|
||||
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
|
||||
if (analysis.status === 'duplicate') duplicateRows.push(candidate.row);
|
||||
}
|
||||
for (const candidate of prepared.analyses) {
|
||||
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
|
||||
if (analysis.status === 'duplicate') duplicateRows.push(candidate.row);
|
||||
}
|
||||
|
||||
if (duplicateRows.length === 0) return;
|
||||
if (duplicateRows.length === 0) return;
|
||||
|
||||
for (const row of duplicateRows) {
|
||||
await this.cleanupDiscardedBookDockFile(row);
|
||||
discardedFileIds.push(row.id);
|
||||
}
|
||||
await this.repo.deleteByIds(duplicateRows.map((row) => row.id));
|
||||
discarded += duplicateRows.length;
|
||||
});
|
||||
for (const row of duplicateRows) {
|
||||
await this.cleanupDiscardedBookDockFile(row);
|
||||
discardedFileIds.push(row.id);
|
||||
}
|
||||
await this.repo.deleteByIds(duplicateRows.map((row) => row.id));
|
||||
discarded += duplicateRows.length;
|
||||
},
|
||||
);
|
||||
|
||||
this.emitChange();
|
||||
const result = { total, discarded, skipped: total - discarded, discardedFileIds: selectAll ? [] : discardedFileIds };
|
||||
@@ -558,6 +582,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
excludedIds: number[] | undefined,
|
||||
status: string | undefined,
|
||||
search: string | undefined,
|
||||
needsReview: boolean | undefined,
|
||||
processBatch: (rows: BookDockFileRow[], missingIds: number[]) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (selectAll) {
|
||||
@@ -569,6 +594,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
});
|
||||
@@ -690,8 +716,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
canManageAll: boolean | undefined,
|
||||
status?: string,
|
||||
search?: string,
|
||||
needsReview?: boolean,
|
||||
): Promise<{ fileId: number; fileName: string; newName: string }[]> {
|
||||
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, canManageAll) : (fileIds ?? []);
|
||||
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, canManageAll, needsReview) : (fileIds ?? []);
|
||||
if (!ids.length) return [];
|
||||
|
||||
const rows = await this.repo.findByIds(ids, userId, canManageAll);
|
||||
|
||||
@@ -182,11 +182,24 @@ describe('BookDockController', () => {
|
||||
await controller.pause();
|
||||
await controller.resume();
|
||||
|
||||
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false);
|
||||
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined);
|
||||
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined);
|
||||
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined);
|
||||
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, true, [1], false, [], 2, 3, [], undefined, undefined);
|
||||
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false, undefined);
|
||||
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.discardDuplicateCandidates).toHaveBeenCalledWith(
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
[10],
|
||||
false,
|
||||
[],
|
||||
2,
|
||||
3,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, true, [1], false, [], 2, 3, [], undefined, undefined, undefined);
|
||||
expect(watcherService.rescan).toHaveBeenCalled();
|
||||
expect(service.pauseProcessing).toHaveBeenCalledTimes(1);
|
||||
expect(service.resumeProcessing).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -63,6 +63,7 @@ export class BookDockController {
|
||||
listFiles(@CurrentUser() user: RequestUser, @Query() query: ListBookDockFilesDto) {
|
||||
return this.service.listFiles({
|
||||
status: query.status,
|
||||
needsReview: query.needsReview,
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 20,
|
||||
sort: query.sort ?? 'createdAt',
|
||||
@@ -143,17 +144,44 @@ export class BookDockController {
|
||||
@Post('files/discard')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
bulkDiscard(@CurrentUser() user: RequestUser, @Body() dto: BulkDiscardDto) {
|
||||
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
|
||||
return this.service.bulkDiscard(
|
||||
dto.fileIds ?? [],
|
||||
dto.selectAll,
|
||||
dto.excludedIds,
|
||||
dto.status,
|
||||
dto.search,
|
||||
user.id,
|
||||
this.canManageAll(user),
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/apply-fetched')
|
||||
applyFetched(@CurrentUser() user: RequestUser, @Body() dto: BulkApplyFetchedDto) {
|
||||
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
|
||||
return this.service.bulkApplyFetched(
|
||||
dto.fileIds ?? [],
|
||||
dto.selectAll,
|
||||
dto.excludedIds,
|
||||
dto.status,
|
||||
dto.search,
|
||||
user.id,
|
||||
this.canManageAll(user),
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/retry-fetch')
|
||||
retryFetch(@CurrentUser() user: RequestUser, @Body() dto: BulkRetryFetchDto) {
|
||||
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
|
||||
return this.service.bulkRetryFetch(
|
||||
dto.fileIds,
|
||||
dto.selectAll,
|
||||
dto.excludedIds,
|
||||
dto.status,
|
||||
dto.search,
|
||||
user.id,
|
||||
this.canManageAll(user),
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/set-target')
|
||||
@@ -168,12 +196,22 @@ export class BookDockController {
|
||||
dto.search,
|
||||
user.id,
|
||||
this.canManageAll(user),
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/selection-summary')
|
||||
selectionSummary(@CurrentUser() user: RequestUser, @Body() dto: SelectionSummaryDto) {
|
||||
return this.service.selectionSummary(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
|
||||
return this.service.selectionSummary(
|
||||
dto.fileIds ?? [],
|
||||
dto.selectAll,
|
||||
dto.excludedIds,
|
||||
dto.status,
|
||||
dto.search,
|
||||
user.id,
|
||||
this.canManageAll(user),
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/bulk-edit')
|
||||
@@ -190,6 +228,7 @@ export class BookDockController {
|
||||
dto.search,
|
||||
user.id,
|
||||
this.canManageAll(user),
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,6 +243,7 @@ export class BookDockController {
|
||||
this.canManageAll(user),
|
||||
dto.status,
|
||||
dto.search,
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,6 +263,7 @@ export class BookDockController {
|
||||
dto.overrides,
|
||||
dto.status,
|
||||
dto.search,
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,6 +283,7 @@ export class BookDockController {
|
||||
dto.overrides,
|
||||
dto.status,
|
||||
dto.search,
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,6 +313,7 @@ export class BookDockController {
|
||||
dto.overrides,
|
||||
dto.status,
|
||||
dto.search,
|
||||
dto.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ vi.mock('drizzle-orm', () => ({
|
||||
gt: vi.fn((left: unknown, right: unknown) => ({ op: 'gt', left, right })),
|
||||
ilike: vi.fn((left: unknown, right: unknown) => ({ op: 'ilike', left, right })),
|
||||
inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })),
|
||||
isNotNull: vi.fn((value: unknown) => ({ op: 'isNotNull', value })),
|
||||
isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })),
|
||||
lt: vi.fn((left: unknown, right: unknown) => ({ op: 'lt', left, right })),
|
||||
notInArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'notInArray', left, right })),
|
||||
or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })),
|
||||
sql: Object.assign(
|
||||
@@ -115,18 +117,21 @@ describe('BookDockRepository', () => {
|
||||
it('countsByStatus maps queue statuses and computes total', async () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
selectBuilder.groupBy.mockResolvedValue([
|
||||
{ status: 'pending', cnt: '3' },
|
||||
{ status: 'ready', cnt: '5' },
|
||||
{ status: 'error', cnt: '2' },
|
||||
{ status: 'extracting', cnt: '1' },
|
||||
{ status: 'fetching', cnt: '4' },
|
||||
{ status: 'pending', cnt: '3', needsReview: '0', readyToFile: '0' },
|
||||
{ status: 'ready', cnt: '5', needsReview: '2', readyToFile: '3' },
|
||||
{ status: 'error', cnt: '2', needsReview: '0', readyToFile: '0' },
|
||||
{ status: 'extracting', cnt: '1', needsReview: '0', readyToFile: '0' },
|
||||
{ status: 'fetching', cnt: '4', needsReview: '0', readyToFile: '0' },
|
||||
]);
|
||||
const repo = new BookDockRepository(db as never);
|
||||
|
||||
await expect(repo.countsByStatus()).resolves.toEqual({
|
||||
pending: 8,
|
||||
working: 5,
|
||||
ready: 5,
|
||||
error: 2,
|
||||
needsReview: 2,
|
||||
readyToFile: 3,
|
||||
total: 15,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, count, desc, eq, gt, inArray, notInArray, sum, type SQL } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, notInArray, or, sql, sum, type SQL } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -17,8 +17,29 @@ const SORT_COLUMNS = {
|
||||
fileSize: bookDockFiles.fileSize,
|
||||
} as const;
|
||||
|
||||
/** Below this, a provider match is treated as a guess the user should confirm. */
|
||||
export const NEEDS_REVIEW_CONFIDENCE_BELOW = 70;
|
||||
|
||||
/**
|
||||
* A file is ready to file but still wants a human: either nothing decided where it
|
||||
* goes, or the provider match is too weak to trust. Both are plain indexed columns,
|
||||
* so this stays a WHERE clause rather than post-filtering a page in memory.
|
||||
*/
|
||||
/** Exactly what finalize accepts: ready, with both halves of a destination resolved. */
|
||||
function readyToFileCondition(): SQL {
|
||||
return and(eq(bookDockFiles.status, 'ready'), isNotNull(bookDockFiles.targetLibraryId), isNotNull(bookDockFiles.targetFolderId)) as SQL;
|
||||
}
|
||||
|
||||
function needsReviewCondition(): SQL {
|
||||
return and(
|
||||
eq(bookDockFiles.status, 'ready'),
|
||||
or(isNull(bookDockFiles.targetLibraryId), isNull(bookDockFiles.targetFolderId), lt(bookDockFiles.confidence, NEEDS_REVIEW_CONFIDENCE_BELOW)),
|
||||
) as SQL;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
status?: string;
|
||||
needsReview?: boolean;
|
||||
page: number;
|
||||
limit: number;
|
||||
sort: string;
|
||||
@@ -33,6 +54,7 @@ export interface SelectionBatchOptions {
|
||||
afterId?: number;
|
||||
excludedIds?: number[];
|
||||
status?: string;
|
||||
needsReview?: boolean;
|
||||
search?: string;
|
||||
userId: number;
|
||||
canManageAll: boolean;
|
||||
@@ -43,20 +65,22 @@ 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);
|
||||
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.canManageAll, opts.needsReview);
|
||||
|
||||
const where = conditions.length ? and(...conditions) : undefined;
|
||||
|
||||
const sortKey = opts.sort as keyof typeof SORT_COLUMNS;
|
||||
const sortCol = SORT_COLUMNS[sortKey] ?? bookDockFiles.createdAt;
|
||||
const orderFn = opts.order === 'asc' ? asc : desc;
|
||||
const orderBy =
|
||||
opts.sort === 'attention'
|
||||
? [this.attentionRank(), desc(bookDockFiles.createdAt), desc(bookDockFiles.id)]
|
||||
: [orderFn(SORT_COLUMNS[opts.sort as keyof typeof SORT_COLUMNS] ?? bookDockFiles.createdAt), orderFn(bookDockFiles.id)];
|
||||
|
||||
const [items, [{ total }]] = await Promise.all([
|
||||
this.db
|
||||
.select()
|
||||
.from(bookDockFiles)
|
||||
.where(where)
|
||||
.orderBy(orderFn(sortCol), orderFn(bookDockFiles.id))
|
||||
.orderBy(...orderBy)
|
||||
.limit(opts.limit)
|
||||
.offset((opts.page - 1) * opts.limit),
|
||||
this.db.select({ total: count() }).from(bookDockFiles).where(where),
|
||||
@@ -65,6 +89,20 @@ export class BookDockRepository {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decisions first, then failures, then settled files, then work still in flight -
|
||||
* which the client also surfaces as a progress readout, so it never needs the top
|
||||
* of the list.
|
||||
*/
|
||||
private attentionRank(): SQL {
|
||||
return sql`case
|
||||
when ${needsReviewCondition()} then 0
|
||||
when ${bookDockFiles.status} = 'error' then 1
|
||||
when ${bookDockFiles.status} = 'ready' then 2
|
||||
when ${bookDockFiles.status} = 'pending' then 3
|
||||
else 4 end`;
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<BookDockFileRow | undefined> {
|
||||
const [row] = await this.db.select().from(bookDockFiles).where(eq(bookDockFiles.id, id)).limit(1);
|
||||
return row;
|
||||
@@ -98,8 +136,15 @@ export class BookDockRepository {
|
||||
await this.db.delete(bookDockFiles).where(eq(bookDockFiles.absolutePath, path));
|
||||
}
|
||||
|
||||
async findAllIds(excludedIds?: number[], status?: string, search?: string, userId?: number, canManageAll?: boolean): Promise<number[]> {
|
||||
const conditions = this.buildSelectionConditions(status, search, userId, canManageAll ?? true);
|
||||
async findAllIds(
|
||||
excludedIds?: number[],
|
||||
status?: string,
|
||||
search?: string,
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
needsReview?: boolean,
|
||||
): Promise<number[]> {
|
||||
const conditions = this.buildSelectionConditions(status, search, userId, canManageAll ?? true, needsReview);
|
||||
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);
|
||||
@@ -134,7 +179,7 @@ export class BookDockRepository {
|
||||
}
|
||||
|
||||
async findSelectionBatch(options: SelectionBatchOptions): Promise<BookDockFileRow[]> {
|
||||
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.canManageAll);
|
||||
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.canManageAll, options.needsReview);
|
||||
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;
|
||||
@@ -151,23 +196,37 @@ export class BookDockRepository {
|
||||
return updated.length;
|
||||
}
|
||||
|
||||
async countsByStatus(userId?: number, canManageAll?: boolean): Promise<{ pending: number; ready: number; error: number; total: number }> {
|
||||
async countsByStatus(
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
): Promise<{ pending: number; working: number; ready: number; error: number; needsReview: number; readyToFile: number; total: number }> {
|
||||
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, canManageAll ?? true) : undefined;
|
||||
|
||||
// A filtered aggregate keeps this to one round trip: only the 'ready' group can
|
||||
// contribute, since needing review presupposes the file is otherwise done.
|
||||
const rows = await this.db
|
||||
.select({
|
||||
status: bookDockFiles.status,
|
||||
cnt: count(),
|
||||
needsReview: sql<number>`count(*) filter (where ${needsReviewCondition()})`,
|
||||
readyToFile: sql<number>`count(*) filter (where ${readyToFileCondition()})`,
|
||||
})
|
||||
.from(bookDockFiles)
|
||||
.where(visibilityCondition)
|
||||
.groupBy(bookDockFiles.status);
|
||||
|
||||
const result = { pending: 0, ready: 0, error: 0, total: 0 };
|
||||
// `pending` keeps its existing meaning of everything not yet settled, so clients
|
||||
// that already read it are unaffected; `working` is the in-flight subset of it.
|
||||
const result = { pending: 0, working: 0, ready: 0, error: 0, needsReview: 0, readyToFile: 0, total: 0 };
|
||||
for (const row of rows) {
|
||||
const n = Number(row.cnt);
|
||||
if (row.status === 'pending' || row.status === 'extracting' || row.status === 'fetching') result.pending += n;
|
||||
else if (row.status === 'ready') result.ready = n;
|
||||
if (row.status === 'pending' || row.status === 'extracting' || row.status === 'fetching') {
|
||||
result.pending += n;
|
||||
if (row.status !== 'pending') result.working += n;
|
||||
} else if (row.status === 'ready') result.ready = n;
|
||||
else if (row.status === 'error') result.error = n;
|
||||
result.needsReview += Number(row.needsReview ?? 0);
|
||||
result.readyToFile += Number(row.readyToFile ?? 0);
|
||||
result.total += n;
|
||||
}
|
||||
return result;
|
||||
@@ -206,13 +265,14 @@ export class BookDockRepository {
|
||||
return eq(bookDockFiles.uploadedBy, userId);
|
||||
}
|
||||
|
||||
private buildSelectionConditions(status?: string, search?: string, userId?: number, canManageAll?: boolean): SQL[] {
|
||||
private buildSelectionConditions(status?: string, search?: string, userId?: number, canManageAll?: boolean, needsReview?: boolean): SQL[] {
|
||||
const conditions: SQL[] = [];
|
||||
if (status === 'pending') {
|
||||
conditions.push(inArray(bookDockFiles.status, ['pending', 'extracting', 'fetching']));
|
||||
} else if (status) {
|
||||
conditions.push(eq(bookDockFiles.status, status));
|
||||
}
|
||||
if (needsReview) conditions.push(needsReviewCondition());
|
||||
if (search) conditions.push(accentInsensitiveIlike(bookDockFiles.fileName, `%${search}%`));
|
||||
if (userId !== undefined && !canManageAll) {
|
||||
conditions.push(eq(bookDockFiles.uploadedBy, userId));
|
||||
|
||||
@@ -92,6 +92,7 @@ export class BookDockService {
|
||||
search?: string,
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
needsReview?: boolean,
|
||||
): Promise<void> {
|
||||
await this.processSelectionRows(
|
||||
{
|
||||
@@ -100,6 +101,7 @@ export class BookDockService {
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
},
|
||||
@@ -123,6 +125,7 @@ export class BookDockService {
|
||||
search?: string,
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
needsReview?: boolean,
|
||||
): Promise<{ total: number; updated: number; failed: number }> {
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
@@ -133,6 +136,7 @@ export class BookDockService {
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
},
|
||||
@@ -173,6 +177,7 @@ export class BookDockService {
|
||||
search?: string,
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
needsReview?: boolean,
|
||||
): Promise<{ total: number; applied: number; skipped: number; skippedEdited: number }> {
|
||||
let applied = 0;
|
||||
let skipped = 0;
|
||||
@@ -184,6 +189,7 @@ export class BookDockService {
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
},
|
||||
@@ -217,6 +223,7 @@ export class BookDockService {
|
||||
search?: string,
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
needsReview?: boolean,
|
||||
): Promise<{ total: number; queued: number }> {
|
||||
let queued = 0;
|
||||
const total = await this.processSelectionRows(
|
||||
@@ -226,6 +233,7 @@ export class BookDockService {
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
},
|
||||
@@ -251,6 +259,7 @@ export class BookDockService {
|
||||
search?: string,
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
needsReview?: boolean,
|
||||
): Promise<{ total: number; updated: number; failed: number }> {
|
||||
await this.assertValidTarget(targetLibraryId, targetFolderId);
|
||||
let updated = 0;
|
||||
@@ -261,6 +270,7 @@ export class BookDockService {
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
},
|
||||
@@ -284,6 +294,7 @@ export class BookDockService {
|
||||
search?: string,
|
||||
userId?: number,
|
||||
canManageAll?: boolean,
|
||||
needsReview?: boolean,
|
||||
): Promise<{ total: number; withDestination: number; withoutDestination: number }> {
|
||||
const destinationPairCounts = new Map<string, number>();
|
||||
const folderIdSet = new Set<number>();
|
||||
@@ -294,6 +305,7 @@ export class BookDockService {
|
||||
excludedIds,
|
||||
status,
|
||||
search,
|
||||
needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
},
|
||||
@@ -460,6 +472,7 @@ export class BookDockService {
|
||||
excludedIds?: number[];
|
||||
status?: string;
|
||||
search?: string;
|
||||
needsReview?: boolean;
|
||||
userId?: number;
|
||||
canManageAll?: boolean;
|
||||
},
|
||||
@@ -478,6 +491,7 @@ export class BookDockService {
|
||||
excludedIds: options.excludedIds,
|
||||
status: options.status,
|
||||
search: options.search,
|
||||
needsReview: options.needsReview,
|
||||
userId,
|
||||
canManageAll,
|
||||
});
|
||||
|
||||
@@ -187,6 +187,10 @@ export class FinalizeBookDockDto {
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== undefined)
|
||||
@IsInt()
|
||||
@@ -226,6 +230,10 @@ export class BulkDiscardDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
}
|
||||
|
||||
export class PreviewNamesDto {
|
||||
@@ -251,6 +259,10 @@ export class PreviewNamesDto {
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== undefined)
|
||||
@IsInt()
|
||||
@@ -279,6 +291,10 @@ export class BulkRetryFetchDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
}
|
||||
|
||||
export class BulkApplyFetchedDto {
|
||||
@@ -303,6 +319,10 @@ export class BulkApplyFetchedDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
}
|
||||
|
||||
export class BulkSetTargetDto {
|
||||
@@ -328,6 +348,10 @@ export class BulkSetTargetDto {
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@IsInt()
|
||||
@@ -361,6 +385,10 @@ export class SelectionSummaryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
}
|
||||
|
||||
export class BulkEditBookDockDto {
|
||||
@@ -386,6 +414,10 @@ export class BulkEditBookDockDto {
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => BookDockMetadataFieldsDto)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
const VALID_STATUSES = ['pending', 'ready', 'error'] as const;
|
||||
const VALID_SORT_FIELDS = ['createdAt', 'fileName', 'format', 'status', 'fileSize'] as const;
|
||||
const VALID_SORT_FIELDS = ['createdAt', 'fileName', 'format', 'status', 'fileSize', 'attention'] as const;
|
||||
const VALID_ORDERS = ['asc', 'desc'] as const;
|
||||
|
||||
export class ListBookDockFilesDto {
|
||||
@@ -10,6 +10,11 @@ export class ListBookDockFilesDto {
|
||||
@IsIn(VALID_STATUSES)
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true' || value === '1')
|
||||
@IsBoolean()
|
||||
needsReview?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value as string, 10))
|
||||
@IsInt()
|
||||
|
||||
Reference in New Issue
Block a user