mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: add bulk tag update and improve selection action bar
This commit is contained in:
+14
-11
@@ -6,6 +6,9 @@
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"prestart": "pnpm --filter @projectx/types build",
|
||||
"prestart:dev": "pnpm --filter @projectx/types build",
|
||||
"prestart:debug": "pnpm --filter @projectx/types build",
|
||||
"prebuild": "pnpm --filter @projectx/types build",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -39,17 +42,17 @@
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.18",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@projectx/types": "workspace:*",
|
||||
"@types/archiver": "^7.0.0",
|
||||
@@ -59,9 +62,9 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fast-xml-parser": "^5.5.11",
|
||||
"fast-xml-parser": "^5.5.12",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify": "^5.8.5",
|
||||
"jose": "^6.2.2",
|
||||
"mediainfo.js": "^0.3.7",
|
||||
"ms": "^2.1.3",
|
||||
@@ -87,9 +90,9 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nestjs/cli": "^11.0.19",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@swc/cli": "^0.8.1",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@swc/core": "^1.15.26",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.6.0",
|
||||
@@ -105,7 +108,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"globals": "^17.5.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.2",
|
||||
"prettier": "^3.8.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-loader": "^9.5.7",
|
||||
@@ -113,7 +116,7 @@
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,10 @@ export interface AuditableOptions {
|
||||
description: string | ((req: AuditRequest, responseBody: unknown) => string);
|
||||
}
|
||||
|
||||
export const Auditable = (options: AuditableOptions) => SetMetadata(AUDITABLE_KEY, options);
|
||||
export const Auditable = (options: AuditableOptions) => {
|
||||
if (!options.action) {
|
||||
throw new Error('Auditable decorator requires a defined audit action');
|
||||
}
|
||||
|
||||
return SetMetadata(AUDITABLE_KEY, options);
|
||||
};
|
||||
|
||||
@@ -39,4 +39,13 @@ describe('common decorators', () => {
|
||||
expect(Reflect.getMetadata(LIBRARY_ACCESS_KEY, DecoratedController.prototype.withLibraryAccess)).toBe('viewer');
|
||||
expect(Reflect.getMetadata(AUDITABLE_KEY, DecoratedController.prototype.audited)).toEqual(auditableOptions);
|
||||
});
|
||||
|
||||
it('throws when audit action is undefined at decorator creation time', () => {
|
||||
expect(() =>
|
||||
Auditable({
|
||||
action: undefined as never,
|
||||
description: 'Missing action',
|
||||
}),
|
||||
).toThrow('Auditable decorator requires a defined audit action');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,4 +38,13 @@ export class BookMetadataLockRepository {
|
||||
.values({ bookId, lockedFields, updatedAt: now })
|
||||
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { lockedFields, updatedAt: now } });
|
||||
}
|
||||
|
||||
async bulkReplaceLockedFields(bookIds: number[], lockedFields: BookMetadataLockField[]): Promise<void> {
|
||||
if (bookIds.length === 0) return;
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.insert(bookMetadata)
|
||||
.values(bookIds.map((bookId) => ({ bookId, lockedFields, updatedAt: now })))
|
||||
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { lockedFields, updatedAt: now } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ export class BookMetadataLockService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async bulkReplaceLockedFields(bookIds: number[], lockedFields: BookMetadataLockField[]): Promise<void> {
|
||||
await this.lockRepo.bulkReplaceLockedFields(bookIds, lockedFields);
|
||||
}
|
||||
|
||||
async getCoverLockedBookIds(bookIds: number[]): Promise<Set<number>> {
|
||||
const fieldsMap = await this.lockRepo.findLockedFieldsByBookIds(bookIds);
|
||||
const locked = new Set<number>();
|
||||
|
||||
@@ -29,6 +29,10 @@ import { FileWriteService } from '../file-write/file-write.service';
|
||||
import { BookService } from './book.service';
|
||||
import { BookQueryPipe } from './pipes/book-query.pipe';
|
||||
import { BulkBookIdsDto } from './dto/bulk-book-ids.dto';
|
||||
import { BulkSetStatusDto } from './dto/bulk-set-status.dto';
|
||||
import { BulkSetRatingDto } from './dto/bulk-set-rating.dto';
|
||||
import { BulkUpdateTagsDto } from './dto/bulk-update-tags.dto';
|
||||
import { BulkSetMetadataLockDto } from './dto/bulk-set-metadata-lock.dto';
|
||||
import { DeleteBooksDto } from './dto/delete-books.dto';
|
||||
import { ExportBooksDto } from './dto/export-books.dto';
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
@@ -492,6 +496,69 @@ export class BookController {
|
||||
return this.bookService.setReadStatus(id, dto.status, user);
|
||||
}
|
||||
|
||||
@Post('bulk-set-status')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Auditable({
|
||||
action: AuditAction.BookBulkSetStatus,
|
||||
resource: AuditResource.Book,
|
||||
description: (req) => {
|
||||
const body = req.body as { bookIds?: number[]; status?: string };
|
||||
const count = body?.bookIds?.length ?? 0;
|
||||
return `Bulk set status to ${body?.status ?? 'unknown'} for ${count} book${count !== 1 ? 's' : ''}`;
|
||||
},
|
||||
})
|
||||
bulkSetStatus(@Body() dto: BulkSetStatusDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.bulkSetStatus(dto.bookIds, dto.status, user);
|
||||
}
|
||||
|
||||
@Post('bulk-set-rating')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
@Auditable({
|
||||
action: AuditAction.BookBulkSetRating,
|
||||
resource: AuditResource.Book,
|
||||
description: (req) => {
|
||||
const body = req.body as { bookIds?: number[]; rating?: number | null };
|
||||
const count = body?.bookIds?.length ?? 0;
|
||||
return `Bulk set rating to ${body?.rating ?? 'null'} for ${count} book${count !== 1 ? 's' : ''}`;
|
||||
},
|
||||
})
|
||||
bulkSetRating(@Body() dto: BulkSetRatingDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.bulkSetRating(dto.bookIds, dto.rating ?? null, user);
|
||||
}
|
||||
|
||||
@Post('bulk-update-tags')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
@Auditable({
|
||||
action: AuditAction.BookBulkUpdateTags,
|
||||
resource: AuditResource.Book,
|
||||
description: (req) => {
|
||||
const body = req.body as { bookIds?: number[]; mode?: string };
|
||||
const count = body?.bookIds?.length ?? 0;
|
||||
return `Bulk ${body?.mode ?? 'update'} tags for ${count} book${count !== 1 ? 's' : ''}`;
|
||||
},
|
||||
})
|
||||
bulkUpdateTags(@Body() dto: BulkUpdateTagsDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.bulkUpdateTags(dto.bookIds, dto.mode, dto.tags, user);
|
||||
}
|
||||
|
||||
@Post('bulk-set-metadata-lock')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
@Auditable({
|
||||
action: AuditAction.BookBulkSetMetadataLock,
|
||||
resource: AuditResource.Book,
|
||||
description: (req) => {
|
||||
const body = req.body as { bookIds?: number[]; locked?: boolean };
|
||||
const count = body?.bookIds?.length ?? 0;
|
||||
return `Bulk ${body?.locked ? 'locked' : 'unlocked'} metadata for ${count} book${count !== 1 ? 's' : ''}`;
|
||||
},
|
||||
})
|
||||
bulkSetMetadataLock(@Body() dto: BulkSetMetadataLockDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.bulkSetMetadataLock(dto.bookIds, dto.locked, user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getDetail(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.getDetail(id, user);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
type MetadataUpdateExecutor = Pick<Db, 'update'>;
|
||||
type MetadataReadExecutor = Pick<Db, 'select'>;
|
||||
type PatternMetadataRow = {
|
||||
bookId: number;
|
||||
title: string | null;
|
||||
@@ -70,6 +71,7 @@ export class BookRepository {
|
||||
language: bookMetadata.language,
|
||||
rating: bookMetadata.rating,
|
||||
coverSource: bookMetadata.coverSource,
|
||||
lockedFields: bookMetadata.lockedFields,
|
||||
})
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
@@ -482,6 +484,27 @@ export class BookRepository {
|
||||
await this.db.delete(books).where(inArray(books.id, bookIds));
|
||||
}
|
||||
|
||||
async bulkSetRating(bookIds: number[], rating: number | null): Promise<void> {
|
||||
if (bookIds.length === 0) return;
|
||||
await this.db.update(bookMetadata).set({ rating, updatedAt: new Date() }).where(inArray(bookMetadata.bookId, bookIds));
|
||||
}
|
||||
|
||||
async findTagsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
|
||||
if (bookIds.length === 0) return new Map();
|
||||
const rows = await executor
|
||||
.select({ bookId: bookTags.bookId, name: tags.name })
|
||||
.from(bookTags)
|
||||
.innerJoin(tags, eq(bookTags.tagId, tags.id))
|
||||
.where(inArray(bookTags.bookId, bookIds));
|
||||
const result = new Map<number, string[]>();
|
||||
for (const row of rows) {
|
||||
const existing = result.get(row.bookId) ?? [];
|
||||
existing.push(row.name);
|
||||
result.set(row.bookId, existing);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateMetadataFields(
|
||||
bookId: number,
|
||||
fields: Partial<typeof bookMetadata.$inferInsert>,
|
||||
|
||||
@@ -82,6 +82,7 @@ function makeService() {
|
||||
findLibraryIdsByBookIds: vi.fn(),
|
||||
findPrimaryFilesByBookIds: vi.fn(),
|
||||
findAllFilesByBookIds: vi.fn(),
|
||||
findTagsByBookIds: vi.fn(),
|
||||
findPrimaryFile: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findCollectionsByBookId: vi.fn(),
|
||||
@@ -95,6 +96,7 @@ function makeService() {
|
||||
upsertProgress: vi.fn(),
|
||||
findAudioProgress: vi.fn(),
|
||||
upsertAudioProgress: vi.fn(),
|
||||
bulkSetRating: vi.fn(),
|
||||
updateMetadataFields: vi.fn(),
|
||||
withTransaction: vi.fn(),
|
||||
deleteByIds: vi.fn(),
|
||||
@@ -189,6 +191,7 @@ function makeService() {
|
||||
libraryService,
|
||||
queryBuilder,
|
||||
metadataService,
|
||||
scoreService,
|
||||
pipeline,
|
||||
config,
|
||||
appSettings,
|
||||
@@ -1490,6 +1493,51 @@ describe('BookService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk metadata actions', () => {
|
||||
it('bulkSetRating updates ratings and queues file writes and score recalculation', async () => {
|
||||
const { service, bookRepo, fileWriteService, scoreService } = makeService();
|
||||
const user = makeUser({ id: 42 });
|
||||
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
|
||||
|
||||
await service.bulkSetRating([3, 5], 4, user);
|
||||
|
||||
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4);
|
||||
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 3, 'auto', 42);
|
||||
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42);
|
||||
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
|
||||
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 5);
|
||||
});
|
||||
|
||||
it('bulkUpdateTags performs all tag changes in one transaction and queues follow-up work', async () => {
|
||||
const { service, bookRepo, metadataService, fileWriteService, scoreService } = makeService();
|
||||
const user = makeUser({ id: 11 });
|
||||
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn() };
|
||||
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
|
||||
bookRepo.findTagsByBookIds.mockResolvedValue(new Map([[7, ['existing']]]));
|
||||
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
|
||||
|
||||
await service.bulkUpdateTags([7], 'add', ['new'], user);
|
||||
|
||||
expect(bookRepo.findTagsByBookIds).toHaveBeenCalledWith([7], tx);
|
||||
expect(metadataService.replaceTags).toHaveBeenCalledWith(7, ['existing', 'new'], { executor: tx });
|
||||
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
|
||||
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('bulkUpdateTags replaces tags inside the transaction executor', async () => {
|
||||
const { service, bookRepo, metadataService } = makeService();
|
||||
const user = makeUser();
|
||||
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn() };
|
||||
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
|
||||
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
|
||||
|
||||
await service.bulkUpdateTags([9], 'replace', ['fresh'], user);
|
||||
|
||||
expect(bookRepo.findTagsByBookIds).not.toHaveBeenCalled();
|
||||
expect(metadataService.replaceTags).toHaveBeenCalledWith(9, ['fresh'], { executor: tx });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDetail and metadata extraction', () => {
|
||||
it('throws NotFoundException when detail lookup has no result', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
|
||||
@@ -20,8 +20,15 @@ import { parseMobiFile } from '../metadata/lib/mobi-parser';
|
||||
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
|
||||
import { basename, extname, join } from 'path';
|
||||
|
||||
import { DEFAULT_DOWNLOAD_PATTERN, MetadataProviderKey, Permission, isAudioFormat, resolveUploadPath } from '@projectx/types';
|
||||
import type { AudiobookChapter, BookKoboState, BookQuery, BooksPage, MetadataField, ReadStatus } from '@projectx/types';
|
||||
import {
|
||||
BOOK_METADATA_LOCK_FIELDS,
|
||||
DEFAULT_DOWNLOAD_PATTERN,
|
||||
MetadataProviderKey,
|
||||
Permission,
|
||||
isAudioFormat,
|
||||
resolveUploadPath,
|
||||
} from '@projectx/types';
|
||||
import type { AudiobookChapter, BookKoboState, BookMetadataLockField, BookQuery, BooksPage, MetadataField, ReadStatus } from '@projectx/types';
|
||||
import { assembleBookCards } from './utils/assemble-book-cards';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
@@ -716,6 +723,68 @@ export class BookService {
|
||||
await this.userBookStatusService.setManual(user.id, bookId, status);
|
||||
}
|
||||
|
||||
async bulkSetStatus(bookIds: number[], status: ReadStatus, user: RequestUser): Promise<void> {
|
||||
const event = 'book.bulk.set_status';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} status=${status} - bulk set status started`);
|
||||
await this.verifyLibraryAccessForBookIds(bookIds, user);
|
||||
await this.userBookStatusService.bulkSetManual(user.id, bookIds, status);
|
||||
this.logger.log(
|
||||
`[${event}] [end] userId=${user.id} count=${bookIds.length} status=${status} durationMs=${Date.now() - startedAt} - bulk set status completed`,
|
||||
);
|
||||
}
|
||||
|
||||
async bulkSetRating(bookIds: number[], rating: number | null, user: RequestUser): Promise<void> {
|
||||
const event = 'book.bulk.set_rating';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} - bulk set rating started`);
|
||||
await this.verifyLibraryAccessForBookIds(bookIds, user);
|
||||
await this.bookRepo.bulkSetRating(bookIds, rating);
|
||||
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
|
||||
this.logger.log(
|
||||
`[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} durationMs=${Date.now() - startedAt} - bulk set rating completed`,
|
||||
);
|
||||
}
|
||||
|
||||
async bulkUpdateTags(bookIds: number[], mode: 'add' | 'remove' | 'replace', tags: string[], user: RequestUser): Promise<void> {
|
||||
const event = 'book.bulk.update_tags';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} - bulk update tags started`);
|
||||
await this.verifyLibraryAccessForBookIds(bookIds, user);
|
||||
await this.bookRepo.withTransaction(async (tx) => {
|
||||
if (mode === 'replace') {
|
||||
for (const bookId of bookIds) {
|
||||
await this.metadataService.replaceTags(bookId, tags, { executor: tx });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTagsMap = await this.bookRepo.findTagsByBookIds(bookIds, tx);
|
||||
for (const bookId of bookIds) {
|
||||
const current = currentTagsMap.get(bookId) ?? [];
|
||||
const next = mode === 'add' ? [...new Set([...current, ...tags])] : current.filter((t) => !tags.includes(t));
|
||||
await this.metadataService.replaceTags(bookId, next, { executor: tx });
|
||||
}
|
||||
});
|
||||
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} durationMs=${Date.now() - startedAt} - bulk update tags completed`,
|
||||
);
|
||||
}
|
||||
|
||||
async bulkSetMetadataLock(bookIds: number[], locked: boolean, user: RequestUser): Promise<void> {
|
||||
const event = 'book.bulk.set_metadata_lock';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} locked=${locked} - bulk set metadata lock started`);
|
||||
await this.verifyLibraryAccessForBookIds(bookIds, user);
|
||||
const fields: BookMetadataLockField[] = locked ? [...BOOK_METADATA_LOCK_FIELDS] : [];
|
||||
await this.bookMetadataLockService.bulkReplaceLockedFields(bookIds, fields);
|
||||
this.logger.log(
|
||||
`[${event}] [end] userId=${user.id} count=${bookIds.length} locked=${locked} durationMs=${Date.now() - startedAt} - bulk set metadata lock completed`,
|
||||
);
|
||||
}
|
||||
|
||||
async getKoboState(id: number, user: RequestUser): Promise<BookKoboState> {
|
||||
await this.verifyBookAccess(id, user);
|
||||
|
||||
@@ -1341,6 +1410,15 @@ export class BookService {
|
||||
);
|
||||
}
|
||||
|
||||
private triggerPostMetadataUpdateEffects(bookIds: number[], userId: number): void {
|
||||
for (const bookId of bookIds) {
|
||||
this.fileWriteService?.scheduleWrite(bookId, 'auto', userId);
|
||||
void this.scoreService
|
||||
.calculateAndSave(bookId)
|
||||
.catch((err: Error) => this.logger.warn(`Score calculation failed for book ${bookId}: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
private resolveChapters(
|
||||
stored: AudiobookChapter[] | null | undefined,
|
||||
fileRows: { absolutePath: string; format: string | null; durationSeconds: number | null }[],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ArrayNotEmpty, IsArray, IsBoolean, IsInt } from 'class-validator';
|
||||
|
||||
export class BulkSetMetadataLockDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
bookIds: number[];
|
||||
|
||||
@IsBoolean()
|
||||
locked!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ArrayNotEmpty, IsArray, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class BulkSetRatingDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
bookIds: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsInt } from 'class-validator';
|
||||
import type { ReadStatus } from '@projectx/types';
|
||||
import { READ_STATUSES } from '../../user-book-status/user-book-status.constants';
|
||||
|
||||
export class BulkSetStatusDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
bookIds: number[];
|
||||
|
||||
@IsIn(READ_STATUSES)
|
||||
status!: ReadStatus;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsString } from 'class-validator';
|
||||
|
||||
const TAG_MODES = ['add', 'remove', 'replace'] as const;
|
||||
export type TagUpdateMode = (typeof TAG_MODES)[number];
|
||||
|
||||
export class BulkUpdateTagsDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
bookIds: number[];
|
||||
|
||||
@IsIn(TAG_MODES)
|
||||
mode!: TagUpdateMode;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags: string[];
|
||||
}
|
||||
@@ -13,6 +13,7 @@ function makeBookRow(id: number, overrides?: Partial<Parameters<typeof assembleB
|
||||
language: null,
|
||||
rating: null,
|
||||
coverSource: null,
|
||||
lockedFields: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -140,6 +141,22 @@ describe('assembleBookCards', () => {
|
||||
expect(card.hasCover).toBe(true);
|
||||
});
|
||||
|
||||
it('marks cards with metadata locks', () => {
|
||||
const rows = [makeBookRow(1, { lockedFields: ['title'] })];
|
||||
|
||||
const [card] = assembleBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card.hasMetadataLocks).toBe(true);
|
||||
});
|
||||
|
||||
it('marks cards without metadata locks as unlocked', () => {
|
||||
const rows = [makeBookRow(1, { lockedFields: [] })];
|
||||
|
||||
const [card] = assembleBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card.hasMetadataLocks).toBe(false);
|
||||
});
|
||||
|
||||
it('includes all optional metadata fields', () => {
|
||||
const rows = [makeBookRow(1, { seriesName: 'Dune', seriesIndex: 1, publishedYear: 1965, language: 'en', rating: 5 })];
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type BookRow = {
|
||||
language: string | null;
|
||||
rating: number | null;
|
||||
coverSource: string | null;
|
||||
lockedFields: string[] | null;
|
||||
};
|
||||
|
||||
type NameRow = { bookId: number; name: string };
|
||||
@@ -101,6 +102,7 @@ export function assembleBookCards(
|
||||
readStatus: statusByBookId.get(row.id) ?? null,
|
||||
addedAt: row.addedAt.toISOString(),
|
||||
hasCover: row.coverSource != null,
|
||||
hasMetadataLocks: (row.lockedFields?.length ?? 0) > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,13 +234,13 @@ describe('ScannerRepository', () => {
|
||||
const { repo, queues } = makeRepo();
|
||||
await expect(repo.findBookCardData([])).resolves.toEqual({ rows: [], authorRows: [], fileRows: [], genreRows: [] });
|
||||
|
||||
queues.select.push([{ id: 1, title: 'Book One' }]);
|
||||
queues.select.push([{ id: 1, title: 'Book One', lockedFields: [] }]);
|
||||
queues.select.push([{ bookId: 1, name: 'Author One' }]);
|
||||
queues.select.push([{ bookId: 1, id: 10, format: 'epub', role: 'content' }]);
|
||||
queues.select.push([{ bookId: 1, name: 'Fiction' }]);
|
||||
|
||||
await expect(repo.findBookCardData([1])).resolves.toEqual({
|
||||
rows: [{ id: 1, title: 'Book One' }],
|
||||
rows: [{ id: 1, title: 'Book One', lockedFields: [] }],
|
||||
authorRows: [{ bookId: 1, name: 'Author One' }],
|
||||
fileRows: [{ bookId: 1, id: 10, format: 'epub', role: 'content' }],
|
||||
genreRows: [{ bookId: 1, name: 'Fiction' }],
|
||||
|
||||
@@ -348,6 +348,7 @@ export class ScannerRepository {
|
||||
language: bookMetadata.language,
|
||||
rating: bookMetadata.rating,
|
||||
coverSource: bookMetadata.coverSource,
|
||||
lockedFields: bookMetadata.lockedFields,
|
||||
})
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
|
||||
@@ -17,6 +17,14 @@ export class UserBookStatusService {
|
||||
await this.repo.upsert(userId, bookId, status, 'manual', new Date());
|
||||
}
|
||||
|
||||
async bulkSetManual(userId: number, bookIds: number[], status: ReadStatus): Promise<void> {
|
||||
if (bookIds.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing = await this.repo.findByBookIds(userId, bookIds);
|
||||
const existingMap = new Map(existing.map((row) => [row.bookId, row]));
|
||||
await Promise.all(bookIds.map((bookId) => this.repo.upsert(userId, bookId, status, 'manual', now, existingMap.get(bookId))));
|
||||
}
|
||||
|
||||
async autoUpdate(
|
||||
userId: number,
|
||||
bookId: number,
|
||||
|
||||
Reference in New Issue
Block a user