mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(books): support reviews, rating removal, and plugin enhancements
Add support for user-scoped personal notes (reviews) and allow clearing book ratings to null, providing full sync capabilities for these actions in the KOReader plugin. Modularize the KOReader plugin codebase into dedicated mixin files to improve maintainability and expand the catalog detail view with related sections (series, author, similar books). Closes #552
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "book_files" DROP CONSTRAINT "book_files_book_folder_consistency_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_book_folder_consistency_fk" FOREIGN KEY ("book_id","library_folder_id") REFERENCES "public"."books"("id","library_folder_id") ON DELETE cascade ON UPDATE cascade;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "user_book_notes" (
|
||||
"user_id" integer NOT NULL,
|
||||
"book_id" integer NOT NULL,
|
||||
"note" text,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_book_notes_user_id_book_id_pk" PRIMARY KEY("user_id","book_id"),
|
||||
CONSTRAINT "user_book_notes_note_length_chk" CHECK ("user_book_notes"."note" is null or char_length("user_book_notes"."note") <= 10000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_book_ratings" DROP CONSTRAINT "user_book_ratings_rating_range_chk";--> statement-breakpoint
|
||||
ALTER TABLE "user_book_ratings" ALTER COLUMN "rating" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user_book_notes" ADD CONSTRAINT "user_book_notes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_book_notes" ADD CONSTRAINT "user_book_notes_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "ubn_user_id_idx" ON "user_book_notes" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "ubn_book_id_idx" ON "user_book_notes" USING btree ("book_id");--> statement-breakpoint
|
||||
CREATE INDEX "ubn_book_user_idx" ON "user_book_notes" USING btree ("book_id","user_id");--> statement-breakpoint
|
||||
ALTER TABLE "user_book_ratings" ADD CONSTRAINT "user_book_ratings_rating_range_chk" CHECK ("user_book_ratings"."rating" is null or ("user_book_ratings"."rating" >= 1 and "user_book_ratings"."rating" <= 5));
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -260,6 +260,20 @@
|
||||
"when": 1783034396046,
|
||||
"tag": "0036_kobo-sync-history",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "7",
|
||||
"when": 1783316940342,
|
||||
"tag": "0037_add_cascade_to_book_files_fk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "7",
|
||||
"when": 1783397661979,
|
||||
"tag": "0038_user_book_notes_and_rating_tombstones",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@ export const bookFiles = pgTable(
|
||||
columns: [t.bookId, t.libraryFolderId],
|
||||
foreignColumns: [books.id, books.libraryFolderId],
|
||||
name: 'book_files_book_folder_consistency_fk',
|
||||
}).onUpdate('cascade'),
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
check('book_files_role_chk', sql`${t.role} in ('content', 'cover', 'metadata', 'supplement')`),
|
||||
check('book_files_size_bytes_nonnegative_chk', sql`${t.sizeBytes} is null or ${t.sizeBytes} >= 0`),
|
||||
check('book_files_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} is null or ${t.durationSeconds} >= 0`),
|
||||
|
||||
@@ -52,7 +52,7 @@ export const userBookRatings = pgTable(
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
rating: integer('rating').notNull(),
|
||||
rating: integer('rating'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
@@ -63,13 +63,40 @@ export const userBookRatings = pgTable(
|
||||
index('ubr_user_id_idx').on(t.userId),
|
||||
index('ubr_book_id_idx').on(t.bookId),
|
||||
index('ubr_book_user_rating_idx').on(t.bookId, t.userId),
|
||||
check('user_book_ratings_rating_range_chk', sql`${t.rating} >= 1 and ${t.rating} <= 5`),
|
||||
check('user_book_ratings_rating_range_chk', sql`${t.rating} is null or (${t.rating} >= 1 and ${t.rating} <= 5)`),
|
||||
],
|
||||
);
|
||||
|
||||
export type UserBookRatingRow = typeof userBookRatings.$inferSelect;
|
||||
export type NewUserBookRating = typeof userBookRatings.$inferInsert;
|
||||
|
||||
export const userBookNotes = pgTable(
|
||||
'user_book_notes',
|
||||
{
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
note: text('note'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.userId, t.bookId] }),
|
||||
index('ubn_user_id_idx').on(t.userId),
|
||||
index('ubn_book_id_idx').on(t.bookId),
|
||||
index('ubn_book_user_idx').on(t.bookId, t.userId),
|
||||
check('user_book_notes_note_length_chk', sql`${t.note} is null or char_length(${t.note}) <= 10000`),
|
||||
],
|
||||
);
|
||||
|
||||
export type UserBookNoteRow = typeof userBookNotes.$inferSelect;
|
||||
export type NewUserBookNote = typeof userBookNotes.$inferInsert;
|
||||
|
||||
export const readingProgress = pgTable(
|
||||
'reading_progress',
|
||||
{
|
||||
|
||||
@@ -1653,6 +1653,17 @@ describe('AchievementRepository', () => {
|
||||
|
||||
expect(result).toBe(9);
|
||||
});
|
||||
|
||||
it('excludes tombstone rows with a null rating', async () => {
|
||||
const chain = makeSelectChain([{ value: 9 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||
const repo = makeRepo(db);
|
||||
|
||||
await repo.countRatings(1);
|
||||
|
||||
const whereSql = flattenSql(chain.where.mock.calls[0][0]);
|
||||
expect(whereSql).toContain('is not null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countRatingsAtMost', () => {
|
||||
@@ -1677,6 +1688,17 @@ describe('AchievementRepository', () => {
|
||||
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('excludes tombstone rows with a null rating', async () => {
|
||||
const chain = makeSelectChain([{ value: 5 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||
const repo = makeRepo(db);
|
||||
|
||||
await repo.countDistinctRatingValues(1);
|
||||
|
||||
const whereSql = flattenSql(chain.where.mock.calls[0][0]);
|
||||
expect(whereSql).toContain('is not null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('existsRatingValue', () => {
|
||||
|
||||
@@ -895,7 +895,10 @@ export class AchievementRepository {
|
||||
// ── Ratings ──
|
||||
|
||||
async countRatings(userId: number): Promise<number> {
|
||||
const [{ value }] = await this.db.select({ value: count() }).from(userBookRatings).where(eq(userBookRatings.userId, userId));
|
||||
const [{ value }] = await this.db
|
||||
.select({ value: count() })
|
||||
.from(userBookRatings)
|
||||
.where(and(eq(userBookRatings.userId, userId), isNotNull(userBookRatings.rating)));
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -911,7 +914,7 @@ export class AchievementRepository {
|
||||
const [{ value }] = await this.db
|
||||
.select({ value: countDistinct(userBookRatings.rating) })
|
||||
.from(userBookRatings)
|
||||
.where(eq(userBookRatings.userId, userId));
|
||||
.where(and(eq(userBookRatings.userId, userId), isNotNull(userBookRatings.rating)));
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -358,7 +358,9 @@ export class BookQueryBuilder {
|
||||
}
|
||||
|
||||
private ratingRuleToSql(operator: string, value: number | undefined, valueTo: number | undefined, userId: number): SQL {
|
||||
const ratingExpr = sql<number>`(SELECT ${userBookRatings.rating} FROM ${userBookRatings} WHERE ${userBookRatings.bookId} = ${books.id} AND ${userBookRatings.userId} = ${userId})`;
|
||||
const ratingExpr = sql<
|
||||
number | null
|
||||
>`(SELECT ${userBookRatings.rating} FROM ${userBookRatings} WHERE ${userBookRatings.bookId} = ${books.id} AND ${userBookRatings.userId} = ${userId})`;
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
|
||||
@@ -150,6 +150,7 @@ function makeController() {
|
||||
saveProgress: vi.fn(),
|
||||
clearFileProgress: vi.fn(),
|
||||
saveAudioProgress: vi.fn(),
|
||||
updatePersonalNote: vi.fn(),
|
||||
updateMetadata: vi.fn(),
|
||||
updateMetadataAndLocks: vi.fn(),
|
||||
updateMetadataLocks: vi.fn(),
|
||||
@@ -197,6 +198,16 @@ describe('BookController', () => {
|
||||
expect(bookService.embedAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates a personal note through the service', async () => {
|
||||
const { controller, bookService } = makeController();
|
||||
const user = makeUser();
|
||||
const updated = { id: 7, personalNote: 'private note' };
|
||||
bookService.updatePersonalNote.mockResolvedValue(updated);
|
||||
|
||||
await expect(controller.updatePersonalNote(7, { note: 'private note' }, user)).resolves.toBe(updated);
|
||||
expect(bookService.updatePersonalNote).toHaveBeenCalledWith(7, { note: 'private note' }, user);
|
||||
});
|
||||
|
||||
it('returns 304 when cover etag matches (unversioned → 24h private cache)', async () => {
|
||||
const { controller, bookService } = makeController();
|
||||
const { reply, headers } = makeReply();
|
||||
|
||||
@@ -45,6 +45,7 @@ import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
|
||||
import { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
|
||||
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
import { UpdatePersonalNoteDto } from './dto/update-personal-note.dto';
|
||||
import { SearchBooksDto } from './dto/search-books.dto';
|
||||
import { UpdateBookFileDto } from './dto/update-book-file.dto';
|
||||
import { SetStatusDto } from '../user-book-status/dto/set-status.dto';
|
||||
@@ -486,6 +487,11 @@ export class BookController {
|
||||
await this.bookService.saveAudioProgress(user.id, id, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':id/personal-note')
|
||||
updatePersonalNote(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePersonalNoteDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.updatePersonalNote(id, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':id/metadata')
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
@Auditable({
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock('../file-write/file-write.module', () => ({ FileWriteModule: class FileW
|
||||
vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} }));
|
||||
vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} }));
|
||||
vi.mock('../metadata-fetch/metadata-fetch.module', () => ({ MetadataFetchModule: class MetadataFetchModule {} }));
|
||||
vi.mock('../user-book-note/user-book-note.module', () => ({ UserBookNoteModule: class UserBookNoteModule {} }));
|
||||
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MetadataModule } from '../metadata/metadata.module';
|
||||
import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module';
|
||||
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
|
||||
import { NarratorModule } from '../narrator/narrator.module';
|
||||
import { UserBookNoteModule } from '../user-book-note/user-book-note.module';
|
||||
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
|
||||
import { BookReadService } from './book-read.service';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
@@ -32,6 +33,7 @@ import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfil
|
||||
AppSettingsModule,
|
||||
MetadataScoreModule,
|
||||
NarratorModule,
|
||||
UserBookNoteModule,
|
||||
UserBookStatusModule,
|
||||
AchievementModule,
|
||||
],
|
||||
|
||||
@@ -904,4 +904,48 @@ describe('BookRepository', () => {
|
||||
expect(readingWhere).toHaveBeenCalledTimes(1);
|
||||
expect(audioWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('bulkSetRating', () => {
|
||||
it('does nothing for an empty book id list', async () => {
|
||||
const db = { insert: vi.fn() };
|
||||
const repo = new BookRepository(db as never);
|
||||
|
||||
await repo.bulkSetRating([], 4, 7);
|
||||
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts a rating row per book id', async () => {
|
||||
const insertChain = makeInsertChain();
|
||||
const db = { insert: vi.fn().mockReturnValue(insertChain) };
|
||||
const repo = new BookRepository(db as never);
|
||||
|
||||
await repo.bulkSetRating([10, 20], 4, 7);
|
||||
|
||||
expect(insertChain.values).toHaveBeenCalledWith([
|
||||
{ userId: 7, bookId: 10, rating: 4 },
|
||||
{ userId: 7, bookId: 20, rating: 4 },
|
||||
]);
|
||||
expect(insertChain.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({ rating: 4 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('upserts a null rating tombstone instead of deleting the row', async () => {
|
||||
const insertChain = makeInsertChain();
|
||||
const db = { insert: vi.fn().mockReturnValue(insertChain) };
|
||||
const repo = new BookRepository(db as never);
|
||||
|
||||
await repo.bulkSetRating([10], null, 7);
|
||||
|
||||
expect(insertChain.values).toHaveBeenCalledWith([{ userId: 7, bookId: 10, rating: null }]);
|
||||
expect(insertChain.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({ rating: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1246,10 +1246,6 @@ export class BookRepository {
|
||||
|
||||
async bulkSetRating(bookIds: number[], rating: number | null, userId: number): Promise<void> {
|
||||
if (bookIds.length === 0) return;
|
||||
if (rating === null) {
|
||||
await this.db.delete(userBookRatings).where(and(eq(userBookRatings.userId, userId), inArray(userBookRatings.bookId, bookIds)));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.insert(userBookRatings)
|
||||
|
||||
@@ -238,6 +238,10 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
findByBookIds: vi.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
const userBookNoteService = {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
setNote: vi.fn().mockResolvedValue({ note: null, updatedAt: '2026-01-01T00:00:00.000Z' }),
|
||||
};
|
||||
|
||||
bookRepo.withTransaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => callback({}));
|
||||
|
||||
@@ -251,6 +255,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
|
||||
config as never,
|
||||
appSettings as never,
|
||||
userBookStatusService as never,
|
||||
userBookNoteService as never,
|
||||
narratorService as never,
|
||||
comicMetadataService as never,
|
||||
customMetadataService as never,
|
||||
@@ -272,6 +277,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
|
||||
config,
|
||||
appSettings,
|
||||
userBookStatusService,
|
||||
userBookNoteService,
|
||||
embedder,
|
||||
fileWriteService,
|
||||
fileRenameService,
|
||||
@@ -2770,6 +2776,34 @@ describe('BookService', () => {
|
||||
expect(bookRepo.findProgress).toHaveBeenCalledWith(77, 1);
|
||||
});
|
||||
|
||||
describe('updatePersonalNote', () => {
|
||||
it('verifies access, persists the note, and returns the refreshed detail', async () => {
|
||||
const { service, userBookNoteService } = makeService();
|
||||
const user = makeUser({ id: 77 });
|
||||
const accessSpy = vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
const detailSpy = vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 10, personalNote: 'Loved it.' } as never);
|
||||
userBookNoteService.setNote.mockResolvedValue({ note: 'Loved it.', updatedAt: '2026-07-01T00:00:00.000Z' });
|
||||
|
||||
const result = await service.updatePersonalNote(10, { note: 'Loved it.' }, user);
|
||||
|
||||
expect(accessSpy).toHaveBeenCalledWith(10, user);
|
||||
expect(userBookNoteService.setNote).toHaveBeenCalledWith(77, 10, 'Loved it.');
|
||||
expect(detailSpy).toHaveBeenCalledWith(10, user);
|
||||
expect(result).toEqual({ id: 10, personalNote: 'Loved it.' });
|
||||
});
|
||||
|
||||
it('normalizes an undefined note to null when clearing', async () => {
|
||||
const { service, userBookNoteService } = makeService();
|
||||
const user = makeUser({ id: 77 });
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 10, personalNote: null } as never);
|
||||
|
||||
await service.updatePersonalNote(10, {}, user);
|
||||
|
||||
expect(userBookNoteService.setNote).toHaveBeenCalledWith(77, 10, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setReadStatus', () => {
|
||||
it('requires at least one updatable field', async () => {
|
||||
const { service } = makeService();
|
||||
@@ -4406,6 +4440,7 @@ describe('BookService', () => {
|
||||
config,
|
||||
appSettings,
|
||||
userBookStatusService,
|
||||
userBookNoteService,
|
||||
narratorService,
|
||||
comicMetadataService,
|
||||
customMetadataService,
|
||||
@@ -4427,6 +4462,7 @@ describe('BookService', () => {
|
||||
config as never,
|
||||
appSettings as never,
|
||||
userBookStatusService as never,
|
||||
userBookNoteService as never,
|
||||
narratorService as never,
|
||||
comicMetadataService as never,
|
||||
customMetadataService as never,
|
||||
@@ -4454,6 +4490,7 @@ describe('BookService', () => {
|
||||
config,
|
||||
appSettings,
|
||||
userBookStatusService,
|
||||
userBookNoteService,
|
||||
narratorService,
|
||||
comicMetadataService,
|
||||
customMetadataService,
|
||||
@@ -4475,6 +4512,7 @@ describe('BookService', () => {
|
||||
config as never,
|
||||
appSettings as never,
|
||||
userBookStatusService as never,
|
||||
userBookNoteService as never,
|
||||
narratorService as never,
|
||||
comicMetadataService as never,
|
||||
customMetadataService as never,
|
||||
|
||||
@@ -70,6 +70,7 @@ import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-
|
||||
import { FileRenameService, RENAME_RELEVANT_FIELDS } from '../file-write/file-rename.service';
|
||||
import { FileWriteService } from '../file-write/file-write.service';
|
||||
import { NarratorService } from '../narrator/narrator.service';
|
||||
import { UserBookNoteService } from '../user-book-note/user-book-note.service';
|
||||
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
|
||||
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
|
||||
@@ -87,6 +88,7 @@ import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
|
||||
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
import { UpdatePersonalNoteDto } from './dto/update-personal-note.dto';
|
||||
import type { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
|
||||
import { buildBookDetailSupplementalFields } from './utils/build-book-detail-supplemental-fields';
|
||||
import type { SetStatusDto } from '../user-book-status/dto/set-status.dto';
|
||||
@@ -239,6 +241,7 @@ export class BookService {
|
||||
private readonly config: ConfigService,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
private readonly userBookStatusService: UserBookStatusService,
|
||||
private readonly userBookNoteService: UserBookNoteService,
|
||||
private readonly narratorService: NarratorService,
|
||||
private readonly comicMetadataService: ComicMetadataRepository,
|
||||
private readonly customMetadataService: CustomMetadataService,
|
||||
@@ -2776,9 +2779,10 @@ export class BookService {
|
||||
|
||||
async getDetail(id: number, user: RequestUser): Promise<BookDetailDto> {
|
||||
await this.verifyBookAccess(id, user);
|
||||
const [result, personalRating, readStatus, comicMeta, collectionRows] = await Promise.all([
|
||||
const [result, personalRating, personalNote, readStatus, comicMeta, collectionRows] = await Promise.all([
|
||||
this.bookRepo.findById(id),
|
||||
this.bookRepo.findRatingByBookAndUser(id, user.id),
|
||||
this.userBookNoteService.findOne(user.id, id),
|
||||
this.userBookStatusService.findOne(user.id, id),
|
||||
this.comicMetadataService.findByBookId(id),
|
||||
this.bookRepo.findCollectionsByBookId(id, user.id),
|
||||
@@ -2825,6 +2829,8 @@ export class BookService {
|
||||
seriesIndex: meta?.seriesIndex ?? null,
|
||||
seriesMemberships: seriesMembershipRows,
|
||||
rating: personalRating,
|
||||
personalNote: personalNote?.note ?? null,
|
||||
personalNoteUpdatedAt: personalNote ? new Date(personalNote.updatedAt) : null,
|
||||
communityRatings: this.mapCommunityRatingRows(communityRatingRows),
|
||||
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
|
||||
hardcoverEditionId: meta?.hardcoverEditionId ?? null,
|
||||
@@ -2918,6 +2924,12 @@ export class BookService {
|
||||
};
|
||||
}
|
||||
|
||||
async updatePersonalNote(id: number, dto: UpdatePersonalNoteDto, user: RequestUser): Promise<BookDetailDto> {
|
||||
await this.verifyBookAccess(id, user);
|
||||
await this.userBookNoteService.setNote(user.id, id, dto.note ?? null);
|
||||
return this.getDetail(id, user);
|
||||
}
|
||||
|
||||
async getMetadataFromFile(id: number, user: RequestUser): Promise<Record<string, unknown>> {
|
||||
await this.verifyBookAccess(id, user);
|
||||
const file = await this.bookRepo.findPrimaryFile(id);
|
||||
|
||||
@@ -51,6 +51,8 @@ export class BookDetailDto {
|
||||
seriesIndex: number | null;
|
||||
seriesMemberships: BookSeriesMembership[];
|
||||
rating: number | null;
|
||||
personalNote: string | null;
|
||||
personalNoteUpdatedAt: Date | null;
|
||||
communityRatings: BookCommunityRating[];
|
||||
coverSource: 'extracted' | 'custom' | null;
|
||||
hardcoverEditionId: string | null;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { GetBooksDto } from './get-books.dto';
|
||||
import { SaveProgressDto } from './save-progress.dto';
|
||||
import { SearchBooksDto } from './search-books.dto';
|
||||
import { UpdateBookMetadataDto } from './update-book-metadata.dto';
|
||||
import { UpdatePersonalNoteDto } from './update-personal-note.dto';
|
||||
import { UpdateRatingDto } from './update-rating.dto';
|
||||
import { UpsertAudioProgressDto } from './upsert-audio-progress.dto';
|
||||
|
||||
@@ -279,6 +280,12 @@ describe('Book DTO validation', () => {
|
||||
expect((await errorsFor(UpdateRatingDto, { rating: 6 })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates personal note updates', async () => {
|
||||
expect((await errorsFor(UpdatePersonalNoteDto, { note: 'My private review' })).length).toBe(0);
|
||||
expect((await errorsFor(UpdatePersonalNoteDto, { note: null })).length).toBe(0);
|
||||
expect((await errorsFor(UpdatePersonalNoteDto, { note: 'a'.repeat(10001) })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates audiobook progress bounds for percentage and position', async () => {
|
||||
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 0, currentFileId: 1, positionSeconds: 0 })).length).toBe(0);
|
||||
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 100, currentFileId: 2, positionSeconds: 120.5 })).length).toBe(0);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdatePersonalNoteDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -218,7 +218,25 @@ export class BookStateDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating?: number;
|
||||
rating?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ratingCleared?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
reviewNote?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
reviewCleared?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DATE_ONLY)
|
||||
reviewModified?: string;
|
||||
}
|
||||
|
||||
export class BookStatesUploadDto extends PluginDeviceDto {
|
||||
|
||||
@@ -145,6 +145,58 @@ function makeService() {
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
}),
|
||||
};
|
||||
const recommendationService = {
|
||||
getSeriesBooks: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 10,
|
||||
title: 'Dune',
|
||||
updatedAt: '2026-02-01T00:00:00.000Z',
|
||||
seriesIndex: 1,
|
||||
hasCover: true,
|
||||
authors: ['Frank Herbert'],
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: 'Dune Messiah',
|
||||
updatedAt: '2026-02-02T00:00:00.000Z',
|
||||
seriesIndex: 2,
|
||||
hasCover: true,
|
||||
authors: ['Frank Herbert'],
|
||||
},
|
||||
]),
|
||||
getAuthorBooks: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 10,
|
||||
title: 'Dune',
|
||||
updatedAt: '2026-02-01T00:00:00.000Z',
|
||||
hasCover: true,
|
||||
authors: ['Frank Herbert'],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'The Dosadi Experiment',
|
||||
updatedAt: '2026-02-03T00:00:00.000Z',
|
||||
hasCover: false,
|
||||
authors: ['Frank Herbert'],
|
||||
},
|
||||
]),
|
||||
getRecommendations: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 11,
|
||||
title: 'Dune Messiah',
|
||||
updatedAt: '2026-02-02T00:00:00.000Z',
|
||||
hasCover: true,
|
||||
authors: ['Frank Herbert'],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: 'Hyperion',
|
||||
updatedAt: '2026-02-04T00:00:00.000Z',
|
||||
hasCover: true,
|
||||
authors: ['Dan Simmons'],
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const service = new KoreaderCatalogService(
|
||||
opdsBookService as never,
|
||||
@@ -152,10 +204,11 @@ function makeService() {
|
||||
bookReadService as never,
|
||||
userBookStatusService as never,
|
||||
dashboardWidgetService as never,
|
||||
recommendationService as never,
|
||||
{ appDataPath: '/data', bookDockPath: '/data/book-dock' },
|
||||
);
|
||||
|
||||
return { service, opdsBookService, bookService, bookReadService, userBookStatusService, dashboardWidgetService };
|
||||
return { service, opdsBookService, bookService, bookReadService, userBookStatusService, dashboardWidgetService, recommendationService };
|
||||
}
|
||||
|
||||
describe('KoreaderCatalogService', () => {
|
||||
@@ -217,6 +270,7 @@ describe('KoreaderCatalogService', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
opdsBookService.getBooksPage.mockResolvedValueOnce({ total: 99, entries: [] });
|
||||
|
||||
opdsBookService.getRandomBooks.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -241,8 +295,12 @@ describe('KoreaderCatalogService', () => {
|
||||
const dashboard = await service.getDashboard(user);
|
||||
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'recently_read', 1, 5, { readStatus: 'reading' }, false, user.contentFilters);
|
||||
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 8, false, user.contentFilters);
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'title_asc', 1, 1, {}, false, user.contentFilters);
|
||||
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 10, false, user.contentFilters);
|
||||
expect(dashboard.sections.map((section) => section.id)).toContain('all-books');
|
||||
expect(dashboard.username).toBe('testuser');
|
||||
expect(dashboard.displayName).toBe('Test User');
|
||||
expect(dashboard.totalBooks).toBe(99);
|
||||
expect(dashboard.continueReading[0]).toEqual(expect.objectContaining({ id: 10, progressPercentage: 47.4, readStatus: 'reading' }));
|
||||
expect(dashboard.discover[0]).toEqual(expect.objectContaining({ id: 22, title: 'Neuromancer' }));
|
||||
expect(dashboard.readingGoal).toEqual({ goalBooks: 24, completedBooks: 6, year: 2026 });
|
||||
@@ -278,7 +336,7 @@ describe('KoreaderCatalogService', () => {
|
||||
|
||||
const result = await service.getDiscover(user);
|
||||
|
||||
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 8, false, user.contentFilters);
|
||||
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 10, false, user.contentFilters);
|
||||
expect(result.discover).toHaveLength(1);
|
||||
expect(result.discover[0]).toEqual(expect.objectContaining({ id: 33, title: 'Frankenstein' }));
|
||||
});
|
||||
@@ -423,8 +481,8 @@ describe('KoreaderCatalogService', () => {
|
||||
expect(result.nextUrl).toContain('page=3');
|
||||
});
|
||||
|
||||
it('maps book detail without exposing absolute paths', async () => {
|
||||
const { service } = makeService();
|
||||
it('maps book detail with related rows without exposing absolute paths', async () => {
|
||||
const { service, recommendationService } = makeService();
|
||||
|
||||
const detail = await service.getBookDetail(makeUser({ id: 7 }), 10);
|
||||
|
||||
@@ -445,8 +503,47 @@ describe('KoreaderCatalogService', () => {
|
||||
downloadUrl: '/api/v1/koreader/plugin/catalog/files/100/download',
|
||||
},
|
||||
],
|
||||
relatedSections: [
|
||||
{
|
||||
id: 'series',
|
||||
title: 'More in series',
|
||||
books: [
|
||||
expect.objectContaining({
|
||||
id: 11,
|
||||
title: 'Dune Messiah',
|
||||
seriesIndex: 2,
|
||||
thumbnailUrl: '/api/v1/koreader/plugin/catalog/books/11/thumbnail',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'author',
|
||||
title: 'Also by this author',
|
||||
books: [
|
||||
expect.objectContaining({
|
||||
id: 12,
|
||||
title: 'The Dosadi Experiment',
|
||||
thumbnailUrl: null,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'similar',
|
||||
title: 'Similar books',
|
||||
books: [
|
||||
expect.objectContaining({
|
||||
id: 13,
|
||||
title: 'Hyperion',
|
||||
thumbnailUrl: '/api/v1/koreader/plugin/catalog/books/13/thumbnail',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(recommendationService.getSeriesBooks).toHaveBeenCalledWith(10, expect.objectContaining({ id: 7 }));
|
||||
expect(recommendationService.getAuthorBooks).toHaveBeenCalledWith(10, expect.objectContaining({ id: 7 }));
|
||||
expect(recommendationService.getRecommendations).toHaveBeenCalledWith(10, expect.objectContaining({ id: 7 }));
|
||||
expect(JSON.stringify(detail)).not.toContain('/books/dune.epub');
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
KoreaderCatalogProgress,
|
||||
KoreaderCatalogRatingResult,
|
||||
KoreaderCatalogReadStatusResult,
|
||||
KoreaderCatalogRelatedBook,
|
||||
KoreaderCatalogRelatedSection,
|
||||
KoreaderCatalogSection,
|
||||
KoreaderCatalogSectionResponse,
|
||||
KoreaderCatalogSeriesSummary,
|
||||
@@ -36,6 +38,7 @@ import type { BookDetailDto } from '../book/dto/book-detail.dto';
|
||||
import { DashboardWidgetService } from '../dashboard/dashboard-widget.service';
|
||||
import { fileMimeType } from '../opds/opds-xml.helpers';
|
||||
import { OpdsBookEntry, OpdsBookService } from '../opds/opds-book.service';
|
||||
import { RecommendationService } from '../recommendation/recommendation.service';
|
||||
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
|
||||
import { KoreaderCatalogBooksQueryDto } from './dto/koreader-catalog-query.dto';
|
||||
|
||||
@@ -48,7 +51,8 @@ type BatchProgressCandidate = BatchProgressRow & { percentage: number; updatedAt
|
||||
const CATALOG_BASE = '/api/v1/koreader/plugin/catalog';
|
||||
const AUTHOR_SERIES_PAGE_SIZE = 60;
|
||||
const DASHBOARD_CONTINUE_READING_LIMIT = 5;
|
||||
const DASHBOARD_DISCOVER_LIMIT = 8;
|
||||
const DASHBOARD_DISCOVER_LIMIT = 10;
|
||||
const DETAIL_RELATED_LIMIT = 8;
|
||||
|
||||
const NATURAL_SORT_ORDER: Record<KoreaderCatalogSort, KoreaderCatalogSortOrder> = {
|
||||
title: 'asc',
|
||||
@@ -118,6 +122,7 @@ export class KoreaderCatalogService {
|
||||
private readonly bookReadService: BookReadService,
|
||||
private readonly userBookStatusService: UserBookStatusService,
|
||||
private readonly dashboardWidgetService: DashboardWidgetService,
|
||||
private readonly recommendationService: RecommendationService,
|
||||
@Inject(storageConfig.KEY) private readonly storage: ConfigType<typeof storageConfig>,
|
||||
) {}
|
||||
|
||||
@@ -133,16 +138,20 @@ export class KoreaderCatalogService {
|
||||
readStatus: 'reading' as const,
|
||||
});
|
||||
|
||||
const [continueReading, discover, readingGoal, readingStreak, highlightOfTheDay] = await Promise.all([
|
||||
const [continueReading, discover, readingGoal, readingStreak, highlightOfTheDay, totalBooks] = await Promise.all([
|
||||
this.getBooksPage(user, continueReadingQuery),
|
||||
this.buildDiscover(user),
|
||||
this.dashboardWidgetService.getReadingGoal(user),
|
||||
this.dashboardWidgetService.getReadingStreak(user),
|
||||
this.dashboardWidgetService.getHighlightOfTheDay(user),
|
||||
this.countBooks(user, {}),
|
||||
]);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
username: user.username,
|
||||
displayName: user.name || user.username,
|
||||
totalBooks,
|
||||
sections: ROOT_SECTIONS.map((section) => ({ ...section })),
|
||||
continueReading: continueReading.items,
|
||||
discover,
|
||||
@@ -218,9 +227,9 @@ export class KoreaderCatalogService {
|
||||
}
|
||||
|
||||
async getBookDetail(user: RequestUser, bookId: number): Promise<KoreaderCatalogBookDetail> {
|
||||
const detail = await this.bookService.getDetail(bookId, user);
|
||||
const [detail, relatedSections] = await Promise.all([this.bookService.getDetail(bookId, user), this.buildRelatedSections(user, bookId)]);
|
||||
const progress = await this.findBestProgress(user.id, detail.id);
|
||||
return this.mapBookDetail(detail, progress);
|
||||
return this.mapBookDetail(detail, progress, relatedSections);
|
||||
}
|
||||
|
||||
async streamThumbnail(user: RequestUser, bookId: number, reply: FastifyReply, ifNoneMatch?: string): Promise<void> {
|
||||
@@ -420,7 +429,11 @@ export class KoreaderCatalogService {
|
||||
};
|
||||
}
|
||||
|
||||
private mapBookDetail(detail: BookDetailDto, progress: KoreaderCatalogProgress | null): KoreaderCatalogBookDetail {
|
||||
private mapBookDetail(
|
||||
detail: BookDetailDto,
|
||||
progress: KoreaderCatalogProgress | null,
|
||||
relatedSections: KoreaderCatalogRelatedSection[] = [],
|
||||
): KoreaderCatalogBookDetail {
|
||||
const title = detail.title ?? (basename(detail.folderPath) || `Book ${detail.id}`);
|
||||
const files = detail.files
|
||||
.filter((file) => file.role === 'primary' || file.role === 'content')
|
||||
@@ -464,6 +477,80 @@ export class KoreaderCatalogService {
|
||||
tags: detail.tags,
|
||||
progress,
|
||||
files,
|
||||
relatedSections,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildRelatedSections(user: RequestUser, bookId: number): Promise<KoreaderCatalogRelatedSection[]> {
|
||||
const [seriesBooks, authorBooks, similarBooks] = await Promise.all([
|
||||
this.safeRelatedLookup(() => this.recommendationService.getSeriesBooks(bookId, user)),
|
||||
this.safeRelatedLookup(() => this.recommendationService.getAuthorBooks(bookId, user)),
|
||||
this.safeRelatedLookup(() => this.recommendationService.getRecommendations(bookId, user)),
|
||||
]);
|
||||
|
||||
const sections: KoreaderCatalogRelatedSection[] = [];
|
||||
const excludedFromSimilar = new Set<number>([bookId]);
|
||||
const series = seriesBooks.filter((book) => book.id !== bookId).slice(0, DETAIL_RELATED_LIMIT);
|
||||
for (const book of series) excludedFromSimilar.add(book.id);
|
||||
if (series.length > 0) {
|
||||
sections.push({
|
||||
id: 'series',
|
||||
title: 'More in series',
|
||||
books: series.map((book) => this.mapRelatedBook(book)),
|
||||
});
|
||||
}
|
||||
|
||||
const authors = authorBooks.filter((book) => book.id !== bookId).slice(0, DETAIL_RELATED_LIMIT);
|
||||
for (const book of authors) excludedFromSimilar.add(book.id);
|
||||
if (authors.length > 0) {
|
||||
sections.push({
|
||||
id: 'author',
|
||||
title: 'Also by this author',
|
||||
books: authors.map((book) => this.mapRelatedBook(book)),
|
||||
});
|
||||
}
|
||||
|
||||
const similar = similarBooks.filter((book) => !excludedFromSimilar.has(book.id)).slice(0, DETAIL_RELATED_LIMIT);
|
||||
if (similar.length > 0) {
|
||||
sections.push({
|
||||
id: 'similar',
|
||||
title: 'Similar books',
|
||||
books: similar.map((book) => this.mapRelatedBook(book)),
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
private async safeRelatedLookup<T>(lookup: () => Promise<T[]>): Promise<T[]> {
|
||||
try {
|
||||
return await lookup();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapRelatedBook(book: {
|
||||
id: number;
|
||||
title: string | null;
|
||||
authors: string[];
|
||||
seriesIndex?: number | null;
|
||||
hasCover: boolean;
|
||||
updatedAt: string | null;
|
||||
isAudiobook?: boolean;
|
||||
isComic?: boolean;
|
||||
}): KoreaderCatalogRelatedBook {
|
||||
return {
|
||||
id: book.id,
|
||||
title: book.title,
|
||||
authors: book.authors,
|
||||
seriesIndex: book.seriesIndex ?? null,
|
||||
hasCover: book.hasCover,
|
||||
thumbnailUrl: book.hasCover ? `${CATALOG_BASE}/books/${book.id}/thumbnail` : null,
|
||||
detailUrl: `${CATALOG_BASE}/books/${book.id}`,
|
||||
updatedAt: book.updatedAt,
|
||||
isAudiobook: book.isAudiobook,
|
||||
isComic: book.isComic,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,20 +10,21 @@ async function readPluginFile(name: string): Promise<string> {
|
||||
|
||||
describe('KOReader plugin update source wiring', () => {
|
||||
it('keeps update status on the first BookOrbit menu page and groups advanced settings', async () => {
|
||||
const main = await readPluginFile('main.lua');
|
||||
const dashboardIndex = main.indexOf('text = _("Open dashboard")');
|
||||
const updateRowIndex = main.indexOf('return self:updateCheckMenuText()');
|
||||
const lastSyncIndex = main.indexOf('return T(_("Last sync: %1")');
|
||||
const syncThisIndex = main.indexOf('text = _("Sync this book now")');
|
||||
const syncAllIndex = main.indexOf('text = _("Sync all books now")');
|
||||
const autoSyncIndex = main.indexOf('text = _("Auto sync this book")');
|
||||
const twoWayIndex = main.indexOf('text = _("Two-way highlight sync")');
|
||||
const syncSettingsIndex = main.indexOf('text = _("Sync settings")');
|
||||
const accountIndex = main.indexOf('text = _("Account & setup")');
|
||||
const syncSettingsBlock = main.slice(syncSettingsIndex, accountIndex);
|
||||
const autoSyncBlock = main.slice(autoSyncIndex, syncSettingsIndex);
|
||||
const menu = await readPluginFile('bookorbit_main_menu.lua');
|
||||
const updater = await readPluginFile('bookorbit_updater.lua');
|
||||
const dashboardIndex = menu.indexOf('text = _("Open dashboard")');
|
||||
const updateRowIndex = menu.indexOf('return self:updateCheckMenuText()');
|
||||
const lastSyncIndex = menu.indexOf('return T(_("Last sync: %1")');
|
||||
const syncThisIndex = menu.indexOf('text = _("Sync this book now")');
|
||||
const syncAllIndex = menu.indexOf('text = _("Sync all books now")');
|
||||
const autoSyncIndex = menu.indexOf('text = _("Auto sync this book")');
|
||||
const twoWayIndex = menu.indexOf('text = _("Two-way highlight sync")');
|
||||
const syncSettingsIndex = menu.indexOf('text = _("Sync settings")');
|
||||
const accountIndex = menu.indexOf('text = _("Account & setup")');
|
||||
const syncSettingsBlock = menu.slice(syncSettingsIndex, accountIndex);
|
||||
const autoSyncBlock = menu.slice(autoSyncIndex, syncSettingsIndex);
|
||||
|
||||
expect(main).toContain('function BookOrbit:updateCheckMenuText()');
|
||||
expect(updater).toContain('function UpdateCheck:updateCheckMenuText()');
|
||||
expect(dashboardIndex).toBeGreaterThan(0);
|
||||
expect(updateRowIndex).toBeGreaterThan(dashboardIndex);
|
||||
expect(updateRowIndex).toBeLessThan(lastSyncIndex);
|
||||
@@ -39,47 +40,49 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(syncSettingsBlock).toContain('return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))');
|
||||
expect(syncSettingsBlock).toContain('return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))');
|
||||
expect(
|
||||
main
|
||||
.slice(main.indexOf('return T(_("Periodically sync every # pages (%1)")'), main.indexOf('return T(_("Sync to a newer state (%1)")'))
|
||||
menu
|
||||
.slice(menu.indexOf('return T(_("Periodically sync every # pages (%1)")'), menu.indexOf('return T(_("Sync to a newer state (%1)")'))
|
||||
.includes('separator = true'),
|
||||
).toBe(false);
|
||||
|
||||
expect(main.indexOf('keep_menu_open = true,\n callback = function()\n self:checkForUpdate()')).toBeGreaterThan(
|
||||
expect(menu.indexOf('keep_menu_open = true,\n callback = function()\n self:checkForUpdate()')).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(main).toContain('return T(_("Plugin update available: v%1 -> v%2"), PLUGIN_VERSION, self.settings.update_latest_version)');
|
||||
expect(main).toContain('return T(_("Installed plugin: v%1 (Check for update)"), PLUGIN_VERSION)');
|
||||
expect(main).toContain('return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)');
|
||||
expect(main).not.toContain('return T(_("Installed plugin: v%1"), PLUGIN_VERSION)');
|
||||
expect(updater).toContain('return T(_("Plugin update available: v%1 -> v%2"), PLUGIN_VERSION, self.settings.update_latest_version)');
|
||||
expect(updater).toContain('return T(_("Installed plugin: v%1 (Check for update)"), PLUGIN_VERSION)');
|
||||
expect(updater).toContain('return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)');
|
||||
expect(updater).not.toContain('return T(_("Installed plugin: v%1"), PLUGIN_VERSION)');
|
||||
});
|
||||
|
||||
it('keeps manual sync actions above advanced sync settings', async () => {
|
||||
const main = await readPluginFile('main.lua');
|
||||
const syncSettingsBlock = main.slice(
|
||||
main.indexOf('return T(_("Periodically sync every # pages (%1)")'),
|
||||
main.indexOf('text = _("Account & setup")'),
|
||||
const menu = await readPluginFile('bookorbit_main_menu.lua');
|
||||
const syncSettingsBlock = menu.slice(
|
||||
menu.indexOf('return T(_("Periodically sync every # pages (%1)")'),
|
||||
menu.indexOf('text = _("Account & setup")'),
|
||||
);
|
||||
|
||||
expect(main.indexOf('text = _("Sync this book now")')).toBeLessThan(main.indexOf('text = _("Sync settings")'));
|
||||
expect(main.indexOf('text = _("Sync all books now")')).toBeLessThan(main.indexOf('text = _("Sync settings")'));
|
||||
expect(menu.indexOf('text = _("Sync this book now")')).toBeLessThan(menu.indexOf('text = _("Sync settings")'));
|
||||
expect(menu.indexOf('text = _("Sync all books now")')).toBeLessThan(menu.indexOf('text = _("Sync settings")'));
|
||||
expect(syncSettingsBlock).not.toContain('text = _("Sync this book now")');
|
||||
expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")');
|
||||
});
|
||||
|
||||
it('reconciles remote progress before manual book sync uploads progress', async () => {
|
||||
const main = await readPluginFile('main.lua');
|
||||
const menu = await readPluginFile('bookorbit_main_menu.lua');
|
||||
const progress = await readPluginFile('bookorbit_progress_sync.lua');
|
||||
const bookSync = await readPluginFile('bookorbit_book_sync.lua');
|
||||
|
||||
expect(main).toContain('function BookOrbit:reconcileProgressBeforeBookSync(digest, on_done)');
|
||||
expect(main).toContain('client:getProgress(digest)');
|
||||
expect(main).toContain('local local_timestamp = self.last_page_turn_timestamp or 0');
|
||||
expect(main).toContain('cancel_callback = function()');
|
||||
expect(main).toContain('on_done(remote_newer)');
|
||||
expect(progress).toContain('function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)');
|
||||
expect(progress).toContain('client:getProgress(digest)');
|
||||
expect(progress).toContain('local local_timestamp = self.last_page_turn_timestamp or 0');
|
||||
expect(progress).toContain('cancel_callback = function()');
|
||||
expect(progress).toContain('on_done(remote_newer)');
|
||||
expect(main).toContain('self:reconcileProgressBeforeBookSync(snap.digest, run_book_sync)');
|
||||
expect(main).toContain('local latest_snap = BookOrbitBookSync.capture(self)');
|
||||
expect(main).toContain('skip_progress = skip_progress');
|
||||
expect(main).toContain('event = "BookOrbitPullProgress"');
|
||||
expect(main).not.toContain('text = _("Pull progress now")');
|
||||
expect(menu).not.toContain('text = _("Pull progress now")');
|
||||
expect(main).toContain('self.onPageUpdate = self._onPageUpdate\n if self.settings.auto_sync then');
|
||||
expect(main).toContain('if self.settings.auto_sync and (self.periodic_push_scheduled');
|
||||
|
||||
@@ -89,15 +92,15 @@ describe('KOReader plugin update source wiring', () => {
|
||||
});
|
||||
|
||||
it('keeps book action downloads compact without duplicating download options', async () => {
|
||||
const catalog = await readPluginFile('bookorbit_catalog.lua');
|
||||
const detail = await readPluginFile('bookorbit_catalog_detail.lua');
|
||||
const download = await readPluginFile('bookorbit_catalog_download.lua');
|
||||
const detailActionsBlock = catalog.slice(
|
||||
catalog.indexOf('function BookOrbitCatalog:showBookActionSheet(detail, opts)'),
|
||||
catalog.indexOf('function BookOrbitCatalog:showDetailActions()'),
|
||||
const detailActionsBlock = detail.slice(
|
||||
detail.indexOf('function CatalogDetail:showBookActionSheet(detail, opts)'),
|
||||
detail.indexOf('function CatalogDetail:showDetailActions()'),
|
||||
);
|
||||
const detailHeaderBlock = catalog.slice(
|
||||
catalog.indexOf('function BookOrbitCatalog:buildDetailHeader'),
|
||||
catalog.indexOf('function BookOrbitCatalog:updateDetailItems'),
|
||||
const detailHeaderBlock = detail.slice(
|
||||
detail.indexOf('function CatalogDetail:buildDetailButtons'),
|
||||
detail.indexOf('function CatalogDetail:updateDetailItems'),
|
||||
);
|
||||
|
||||
expect(detailActionsBlock).toContain('text = _("Download")');
|
||||
@@ -114,13 +117,14 @@ describe('KOReader plugin update source wiring', () => {
|
||||
|
||||
it('throttles automatic update prompts and does not interrupt the catalog browser', async () => {
|
||||
const main = await readPluginFile('main.lua');
|
||||
const updater = await readPluginFile('bookorbit_updater.lua');
|
||||
|
||||
expect(main).toContain('local UPDATE_CHECK_INTERVAL = 24 * 60 * 60');
|
||||
expect(updater).toContain('local UPDATE_CHECK_INTERVAL = 24 * 60 * 60');
|
||||
expect(main).toContain('update_check_last_at = 0');
|
||||
expect(main).toContain('function BookOrbit:maybeCheckForUpdate(interactive)');
|
||||
expect(main).toContain('self:handleUpdateVersionResponse(body, interactive, interactive or self.catalog_browser == nil)');
|
||||
expect(main).toContain('if not prompt_allowed then');
|
||||
expect(main).toContain('self.settings.update_dismissed_version = plugin_latest');
|
||||
expect(updater).toContain('function UpdateCheck:maybeCheckForUpdate(interactive)');
|
||||
expect(updater).toContain('self:handleUpdateVersionResponse(body, interactive, interactive or self.catalog_browser == nil)');
|
||||
expect(updater).toContain('if not prompt_allowed then');
|
||||
expect(updater).toContain('self.settings.update_dismissed_version = plugin_latest');
|
||||
});
|
||||
|
||||
it('runs a throttled update check after successful full-library sweeps', async () => {
|
||||
@@ -134,14 +138,16 @@ describe('KOReader plugin update source wiring', () => {
|
||||
|
||||
it('wires bulk catalog downloads through a dedicated mixin and selection UI hooks', async () => {
|
||||
const catalog = await readPluginFile('bookorbit_catalog.lua');
|
||||
const detail = await readPluginFile('bookorbit_catalog_detail.lua');
|
||||
const bulk = await readPluginFile('bookorbit_catalog_bulk_download.lua');
|
||||
const download = await readPluginFile('bookorbit_catalog_download.lua');
|
||||
const menu = await readPluginFile('bookorbit_main_menu.lua');
|
||||
const main = await readPluginFile('main.lua');
|
||||
const widgets = await readPluginFile('bookorbit_catalog_widgets.lua');
|
||||
const downloadIcon = await readPluginFile('assets/bookorbit.download.svg');
|
||||
const refreshTapBlock = catalog.slice(
|
||||
catalog.indexOf('function BookOrbitCatalog:onRefreshButtonTap()'),
|
||||
catalog.indexOf('function BookOrbitCatalog:loadBookDetail'),
|
||||
catalog.indexOf('function BookOrbitCatalog:dashboardMode()'),
|
||||
);
|
||||
|
||||
expect(catalog).toContain('local CatalogBulkDownload = require("bookorbit_catalog_bulk_download")');
|
||||
@@ -159,8 +165,8 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(catalog).toContain('text = titles_label');
|
||||
expect(catalog).toContain('self:showBulkSelectionActions()');
|
||||
expect(catalog).toContain('function BookOrbitCatalog:onMenuHoldSelect(item)');
|
||||
expect(catalog).toContain('function BookOrbitCatalog:showBookActionSheet(detail, opts)');
|
||||
expect(catalog).toContain('function BookOrbitCatalog:showBookActionSheetForEntry(item)');
|
||||
expect(detail).toContain('function CatalogDetail:showBookActionSheet(detail, opts)');
|
||||
expect(detail).toContain('function CatalogDetail:showBookActionSheetForEntry(item)');
|
||||
expect(catalog).toContain('function BookOrbitCatalog:isBulkSelectionActive()');
|
||||
expect(catalog).toContain('self:bulkHandleContextChange(self.current_context)');
|
||||
expect(catalog).toContain('function BookOrbitCatalog:titleBarSearchIcon()');
|
||||
@@ -179,7 +185,7 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(refreshTapBlock).toContain('self:bulkExitSelectionMode()');
|
||||
expect(refreshTapBlock).not.toContain('self:bulkClearSelectedBooks(true)');
|
||||
expect(catalog).toContain('self:showBookActionSheetForEntry(item)');
|
||||
expect(catalog).toContain('allow_select = item.kind == "book" and self:bookMode()');
|
||||
expect(detail).toContain('allow_select = item.kind == "book" and self:bookMode()');
|
||||
expect(catalog).toContain('text = _("Close BookOrbit")');
|
||||
expect(catalog).not.toContain('"appbar.download"');
|
||||
expect(catalog).not.toContain('close_callback = function() self:onClose() end');
|
||||
@@ -202,8 +208,8 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(download).toContain('local on_catalog_page = (self.bookMode and self:bookMode())');
|
||||
expect(download).toContain('elseif self.updateItems and on_catalog_page then');
|
||||
|
||||
expect(main).toContain('text = _("Close BookOrbit")');
|
||||
expect(main).toContain('catalog:onCloseAllMenus()');
|
||||
expect(menu).toContain('text = _("Close BookOrbit")');
|
||||
expect(menu).toContain('catalog:onCloseAllMenus()');
|
||||
expect(main).toContain('path = self.path');
|
||||
expect(main).toContain('catalog_mosaic_show_titles = false');
|
||||
|
||||
|
||||
@@ -10,6 +10,19 @@ function makeQueryChain(result: unknown) {
|
||||
};
|
||||
chain.from = vi.fn().mockReturnValue(chain);
|
||||
chain.where = vi.fn().mockReturnValue(chain);
|
||||
chain.limit = vi.fn().mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChain(result: unknown) {
|
||||
const chain: Record<string, unknown> = {
|
||||
then(resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) {
|
||||
return Promise.resolve(result).then(resolve, reject);
|
||||
},
|
||||
};
|
||||
chain.values = vi.fn().mockReturnValue(chain);
|
||||
chain.onConflictDoUpdate = vi.fn().mockReturnValue(chain);
|
||||
chain.returning = vi.fn().mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
@@ -17,6 +30,7 @@ function makeDb() {
|
||||
return {
|
||||
execute: vi.fn(),
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,4 +106,38 @@ describe('KoreaderPluginRepository', () => {
|
||||
await expect(repo.getHashLinkVersion(7)).resolves.toEqual({ count: 0, maxTs: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRating', () => {
|
||||
it('returns the current rating row for a user and book', async () => {
|
||||
const updatedAt = new Date('2026-06-01T10:00:00.000Z');
|
||||
db.select.mockReturnValue(makeQueryChain([{ rating: 4, updatedAt }]));
|
||||
|
||||
await expect(repo.getRating(7, 20)).resolves.toEqual({ rating: 4, updatedAt });
|
||||
});
|
||||
|
||||
it('returns null when no rating row exists', async () => {
|
||||
db.select.mockReturnValue(makeQueryChain([]));
|
||||
|
||||
await expect(repo.getRating(7, 20)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertRating', () => {
|
||||
it('returns the canonical rating row written by the upsert', async () => {
|
||||
const updatedAt = new Date('2026-06-02T10:00:00.000Z');
|
||||
const insertChain = makeInsertChain([{ rating: 4, updatedAt }]);
|
||||
db.insert.mockReturnValue(insertChain);
|
||||
|
||||
await expect(repo.upsertRating(7, 20, 4)).resolves.toEqual({ rating: 4, updatedAt });
|
||||
expect(insertChain.values).toHaveBeenCalledWith({ userId: 7, bookId: 20, rating: 4 });
|
||||
});
|
||||
|
||||
it('supports clearing a rating to null', async () => {
|
||||
const updatedAt = new Date('2026-06-03T10:00:00.000Z');
|
||||
const insertChain = makeInsertChain([{ rating: null, updatedAt }]);
|
||||
db.insert.mockReturnValue(insertChain);
|
||||
|
||||
await expect(repo.upsertRating(7, 20, null)).resolves.toEqual({ rating: null, updatedAt });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ export class KoreaderPluginRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getRating(userId: number, bookId: number): Promise<{ rating: number; updatedAt: Date } | null> {
|
||||
async getRating(userId: number, bookId: number): Promise<{ rating: number | null; updatedAt: Date } | null> {
|
||||
const [row] = await this.db
|
||||
.select({ rating: schema.userBookRatings.rating, updatedAt: schema.userBookRatings.updatedAt })
|
||||
.from(schema.userBookRatings)
|
||||
@@ -287,14 +287,16 @@ export class KoreaderPluginRepository {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async upsertRating(userId: number, bookId: number, rating: number) {
|
||||
await this.db
|
||||
async upsertRating(userId: number, bookId: number, rating: number | null): Promise<{ rating: number | null; updatedAt: Date }> {
|
||||
const [row] = await this.db
|
||||
.insert(schema.userBookRatings)
|
||||
.values({ userId, bookId, rating })
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.userBookRatings.userId, schema.userBookRatings.bookId],
|
||||
set: { rating, updatedAt: new Date() },
|
||||
});
|
||||
})
|
||||
.returning({ rating: schema.userBookRatings.rating, updatedAt: schema.userBookRatings.updatedAt });
|
||||
return row!;
|
||||
}
|
||||
|
||||
async upsertSweep(data: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, type AchievementEventsService } from '../achievement/achievement-events.service';
|
||||
import type { UserBookNoteService } from '../user-book-note/user-book-note.service';
|
||||
import type { UserBookStatusService } from '../user-book-status/user-book-status.service';
|
||||
import type { BookStatesUploadDto, BulkProgressDto, MatchCheckDto, SweepCompleteDto } from './dto';
|
||||
import type { KoreaderPluginRepository } from './koreader-plugin.repository';
|
||||
@@ -42,6 +43,12 @@ describe('KoreaderPluginService', () => {
|
||||
};
|
||||
let koreaderService: { applyProgressForResolvedFile: ReturnType<typeof vi.fn> };
|
||||
let userBookStatusService: { findOne: ReturnType<typeof vi.fn>; setManual: ReturnType<typeof vi.fn> };
|
||||
let userBookNoteService: {
|
||||
findOne: ReturnType<typeof vi.fn>;
|
||||
findRow: ReturnType<typeof vi.fn>;
|
||||
setNote: ReturnType<typeof vi.fn>;
|
||||
normalizeNote: (value: string | null | undefined) => string | null;
|
||||
};
|
||||
let achievementEvents: { emit: ReturnType<typeof vi.fn> };
|
||||
let service: KoreaderPluginService;
|
||||
|
||||
@@ -60,7 +67,7 @@ describe('KoreaderPluginService', () => {
|
||||
};
|
||||
pluginRepo = {
|
||||
getRating: vi.fn().mockResolvedValue(null),
|
||||
upsertRating: vi.fn().mockResolvedValue(undefined),
|
||||
upsertRating: vi.fn().mockResolvedValue({ rating: null, updatedAt: new Date('2026-01-01T00:00:00.000Z') }),
|
||||
upsertSweep: vi.fn().mockResolvedValue(new Date('2026-06-09T10:00:00.000Z')),
|
||||
listSweeps: vi.fn().mockResolvedValue([]),
|
||||
getPluginTotals: vi.fn().mockResolvedValue({ matchedBooks: 0, pageStatEvents: 0, annotations: 0, unmatchedBooks: 0 }),
|
||||
@@ -69,6 +76,15 @@ describe('KoreaderPluginService', () => {
|
||||
};
|
||||
koreaderService = { applyProgressForResolvedFile: vi.fn().mockResolvedValue(undefined) };
|
||||
userBookStatusService = { findOne: vi.fn().mockResolvedValue(null), setManual: vi.fn().mockResolvedValue(undefined) };
|
||||
userBookNoteService = {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
findRow: vi.fn().mockResolvedValue(null),
|
||||
setNote: vi.fn().mockResolvedValue({ note: null, updatedAt: '2026-06-01T00:00:00.000Z' }),
|
||||
normalizeNote: (value) => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
},
|
||||
};
|
||||
achievementEvents = { emit: vi.fn() };
|
||||
|
||||
service = new KoreaderPluginService(
|
||||
@@ -76,6 +92,7 @@ describe('KoreaderPluginService', () => {
|
||||
pluginRepo as unknown as KoreaderPluginRepository,
|
||||
koreaderService as unknown as KoreaderService,
|
||||
userBookStatusService as unknown as UserBookStatusService,
|
||||
userBookNoteService as unknown as UserBookNoteService,
|
||||
achievementEvents as unknown as AchievementEventsService,
|
||||
);
|
||||
});
|
||||
@@ -198,7 +215,7 @@ describe('KoreaderPluginService', () => {
|
||||
const result = await service.uploadBookStates(makeUser(), statesDto([{ hash: HASH_A, status: 'complete', statusModified: '2026-06-01' }]));
|
||||
|
||||
expect(userBookStatusService.setManual).toHaveBeenCalledWith(7, 20, 'read');
|
||||
expect(result.results[0]).toEqual({ hash: HASH_A, statusApplied: true, ratingApplied: false });
|
||||
expect(result.results[0]).toMatchObject({ hash: HASH_A, statusApplied: true, ratingApplied: false, reviewApplied: false });
|
||||
});
|
||||
|
||||
it('treats an identical status as applied without writing', async () => {
|
||||
@@ -255,6 +272,66 @@ describe('KoreaderPluginService', () => {
|
||||
|
||||
expect(pluginRepo.upsertRating).toHaveBeenCalledWith(7, 20, 5);
|
||||
});
|
||||
|
||||
it('clears a rating when the device clear is newer', async () => {
|
||||
pluginRepo.getRating.mockResolvedValueOnce({ rating: 2, updatedAt: new Date('2026-06-01T08:00:00.000Z') });
|
||||
pluginRepo.upsertRating.mockResolvedValueOnce({ rating: null, updatedAt: new Date('2026-06-02T08:00:00.000Z') });
|
||||
|
||||
const result = await service.uploadBookStates(makeUser(), statesDto([{ hash: HASH_A, ratingCleared: true, statusModified: '2026-06-02' }]));
|
||||
|
||||
expect(pluginRepo.upsertRating).toHaveBeenCalledWith(7, 20, null);
|
||||
expect(result.results[0]).toMatchObject({ rating: null, ratingSet: false, ratingUpdatedAt: '2026-06-02T08:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('applies a review note when none exists and returns canonical personal state', async () => {
|
||||
userBookNoteService.setNote.mockResolvedValue({ note: 'Loved it.', updatedAt: '2026-06-03T12:00:00.000Z' });
|
||||
|
||||
const result = await service.uploadBookStates(
|
||||
makeUser(),
|
||||
statesDto([{ hash: HASH_A, reviewNote: ' Loved it. ', reviewModified: '2026-06-03' }]),
|
||||
);
|
||||
|
||||
expect(userBookNoteService.setNote).toHaveBeenCalledWith(7, 20, 'Loved it.');
|
||||
expect(result.results[0]).toMatchObject({
|
||||
reviewApplied: true,
|
||||
reviewNote: 'Loved it.',
|
||||
reviewNoteSet: true,
|
||||
reviewUpdatedAt: '2026-06-03T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the server review on a same-day tie', async () => {
|
||||
userBookNoteService.findRow.mockResolvedValue({ note: 'Server note', updatedAt: new Date('2026-06-05T08:00:00.000Z') });
|
||||
|
||||
const result = await service.uploadBookStates(
|
||||
makeUser(),
|
||||
statesDto([{ hash: HASH_A, reviewNote: 'Device note', reviewModified: '2026-06-05' }]),
|
||||
);
|
||||
|
||||
expect(userBookNoteService.setNote).not.toHaveBeenCalled();
|
||||
expect(result.results[0]).toMatchObject({ reviewApplied: false, reviewNote: 'Server note' });
|
||||
});
|
||||
|
||||
it('clears a review when the device clear is newer', async () => {
|
||||
userBookNoteService.findRow.mockResolvedValue({ note: 'Server note', updatedAt: new Date('2026-06-01T08:00:00.000Z') });
|
||||
userBookNoteService.setNote.mockResolvedValue({ note: null, updatedAt: '2026-06-02T08:00:00.000Z' });
|
||||
|
||||
const result = await service.uploadBookStates(makeUser(), statesDto([{ hash: HASH_A, reviewCleared: true, reviewModified: '2026-06-02' }]));
|
||||
|
||||
expect(userBookNoteService.setNote).toHaveBeenCalledWith(7, 20, null);
|
||||
expect(result.results[0]).toMatchObject({ reviewApplied: true, reviewNote: null, reviewNoteSet: false });
|
||||
});
|
||||
|
||||
it('does not re-query canonical rating/review state when the device already supplied them', async () => {
|
||||
pluginRepo.getRating.mockResolvedValue({ rating: 2, updatedAt: new Date('2026-06-01T08:00:00.000Z') });
|
||||
userBookNoteService.findRow.mockResolvedValue({ note: 'Existing', updatedAt: new Date('2026-06-01T08:00:00.000Z') });
|
||||
|
||||
await service.uploadBookStates(makeUser(), statesDto([{ hash: HASH_A, rating: 2, reviewNote: 'Existing', reviewModified: '2026-06-01' }]));
|
||||
|
||||
expect(pluginRepo.getRating).toHaveBeenCalledTimes(1);
|
||||
expect(userBookNoteService.findRow).toHaveBeenCalledTimes(1);
|
||||
expect(userBookNoteService.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkProgress', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReadStatus } from '@bookorbit/types';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, AchievementEventsService } from '../achievement/achievement-events.service';
|
||||
import { UserBookNoteService } from '../user-book-note/user-book-note.service';
|
||||
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
|
||||
import type { BookStatesUploadDto, BulkProgressDto, MatchCheckDto, SweepCompleteDto } from './dto';
|
||||
import { KoreaderPluginRepository } from './koreader-plugin.repository';
|
||||
@@ -29,10 +30,33 @@ export interface MatchCheckResult {
|
||||
}
|
||||
|
||||
export interface BookStatesUploadResult {
|
||||
results: { hash: string; statusApplied: boolean; ratingApplied: boolean }[];
|
||||
results: {
|
||||
hash: string;
|
||||
statusApplied: boolean;
|
||||
ratingApplied: boolean;
|
||||
reviewApplied: boolean;
|
||||
rating: number | null;
|
||||
ratingSet: boolean;
|
||||
ratingUpdatedAt: string | null;
|
||||
reviewNote: string | null;
|
||||
reviewNoteSet: boolean;
|
||||
reviewUpdatedAt: string | null;
|
||||
}[];
|
||||
unmatched: string[];
|
||||
}
|
||||
|
||||
interface RatingApplyResult {
|
||||
applied: boolean;
|
||||
rating: number | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
interface ReviewApplyResult {
|
||||
applied: boolean;
|
||||
note: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BulkProgressResult {
|
||||
results: { hash: string; accepted: boolean }[];
|
||||
unmatched: string[];
|
||||
@@ -53,6 +77,7 @@ export class KoreaderPluginService {
|
||||
private readonly pluginRepo: KoreaderPluginRepository,
|
||||
private readonly koreaderService: KoreaderService,
|
||||
private readonly userBookStatusService: UserBookStatusService,
|
||||
private readonly userBookNoteService: UserBookNoteService,
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
) {}
|
||||
|
||||
@@ -109,18 +134,49 @@ export class KoreaderPluginService {
|
||||
}
|
||||
|
||||
let statusApplied = false;
|
||||
let ratingApplied = false;
|
||||
if (book.status) {
|
||||
statusApplied = await this.applyStatus(user.id, match.bookId, book.status, book.statusModified);
|
||||
}
|
||||
if (book.rating) {
|
||||
ratingApplied = await this.applyRating(user.id, match.bookId, book.rating, book.statusModified);
|
||||
|
||||
const hasRatingField = Object.prototype.hasOwnProperty.call(book, 'rating');
|
||||
let ratingResult: RatingApplyResult;
|
||||
if (hasRatingField || book.ratingCleared === true) {
|
||||
ratingResult = await this.applyRating(user.id, match.bookId, hasRatingField ? (book.rating ?? null) : null, book.statusModified);
|
||||
} else {
|
||||
const current = await this.pluginRepo.getRating(user.id, match.bookId);
|
||||
ratingResult = { applied: false, rating: current?.rating ?? null, updatedAt: current?.updatedAt ?? null };
|
||||
}
|
||||
results.push({ hash, statusApplied, ratingApplied });
|
||||
|
||||
const hasReviewField = Object.prototype.hasOwnProperty.call(book, 'reviewNote');
|
||||
let reviewResult: ReviewApplyResult;
|
||||
if (hasReviewField || book.reviewCleared === true) {
|
||||
reviewResult = await this.applyReview(
|
||||
user.id,
|
||||
match.bookId,
|
||||
hasReviewField ? (book.reviewNote ?? null) : null,
|
||||
book.reviewModified ?? book.statusModified,
|
||||
);
|
||||
} else {
|
||||
const current = await this.userBookNoteService.findOne(user.id, match.bookId);
|
||||
reviewResult = { applied: false, note: current?.note ?? null, updatedAt: current?.updatedAt ?? null };
|
||||
}
|
||||
|
||||
results.push({
|
||||
hash,
|
||||
statusApplied,
|
||||
ratingApplied: ratingResult.applied,
|
||||
reviewApplied: reviewResult.applied,
|
||||
rating: ratingResult.rating,
|
||||
ratingSet: ratingResult.rating != null,
|
||||
ratingUpdatedAt: ratingResult.updatedAt ? ratingResult.updatedAt.toISOString() : null,
|
||||
reviewNote: reviewResult.note,
|
||||
reviewNoteSet: reviewResult.note != null,
|
||||
reviewUpdatedAt: reviewResult.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[${BOOK_STATES_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} applied=${results.filter((r) => r.statusApplied || r.ratingApplied).length} unmatched=${unmatched.length} - book states upload completed`,
|
||||
`[${BOOK_STATES_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} applied=${results.filter((r) => r.statusApplied || r.ratingApplied || r.reviewApplied).length} unmatched=${unmatched.length} - book states upload completed`,
|
||||
);
|
||||
|
||||
return { results, unmatched };
|
||||
@@ -273,17 +329,36 @@ export class KoreaderPluginService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async applyRating(userId: number, bookId: number, rating: number, statusModified?: string): Promise<boolean> {
|
||||
private async applyRating(userId: number, bookId: number, rating: number | null, statusModified?: string): Promise<RatingApplyResult> {
|
||||
const current = await this.pluginRepo.getRating(userId, bookId);
|
||||
if (current) {
|
||||
if (current.rating === rating) return true;
|
||||
if (current.rating === rating) return { applied: true, rating: current.rating, updatedAt: current.updatedAt };
|
||||
const serverDate = current.updatedAt.toISOString().slice(0, 10);
|
||||
if (!statusModified || statusModified <= serverDate) return false;
|
||||
if (!statusModified || statusModified <= serverDate) {
|
||||
return { applied: false, rating: current.rating, updatedAt: current.updatedAt };
|
||||
}
|
||||
}
|
||||
|
||||
await this.pluginRepo.upsertRating(userId, bookId, rating);
|
||||
const updated = await this.pluginRepo.upsertRating(userId, bookId, rating);
|
||||
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { userId, bookIds: [bookId], rating });
|
||||
return true;
|
||||
return { applied: true, rating: updated.rating, updatedAt: updated.updatedAt };
|
||||
}
|
||||
|
||||
private async applyReview(userId: number, bookId: number, reviewNote: string | null, reviewModified?: string): Promise<ReviewApplyResult> {
|
||||
const current = await this.userBookNoteService.findRow(userId, bookId);
|
||||
const normalized = this.userBookNoteService.normalizeNote(reviewNote);
|
||||
if (current) {
|
||||
if ((current.note ?? null) === normalized) {
|
||||
return { applied: true, note: current.note ?? null, updatedAt: current.updatedAt.toISOString() };
|
||||
}
|
||||
const serverDate = current.updatedAt.toISOString().slice(0, 10);
|
||||
if (!reviewModified || reviewModified <= serverDate) {
|
||||
return { applied: false, note: current.note ?? null, updatedAt: current.updatedAt.toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.userBookNoteService.setNote(userId, bookId, normalized);
|
||||
return { applied: true, note: updated.note, updatedAt: updated.updatedAt };
|
||||
}
|
||||
|
||||
private async isStaleProgress(userId: number, bookFileId: number, timestamp?: number): Promise<boolean> {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { BookModule } from '../book/book.module';
|
||||
import { DashboardModule } from '../dashboard/dashboard.module';
|
||||
import { OpdsModule } from '../opds/opds.module';
|
||||
import { PositionConverterModule } from '../position-converter/position-converter.module';
|
||||
import { RecommendationModule } from '../recommendation/recommendation.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { UserBookNoteModule } from '../user-book-note/user-book-note.module';
|
||||
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
|
||||
import { KoreaderAnnotationExchangeService } from './koreader-annotation-exchange.service';
|
||||
import { KoreaderAuthGuard } from './koreader-auth.guard';
|
||||
@@ -30,6 +32,7 @@ import { KoreaderStatsService } from './koreader-stats.service';
|
||||
imports: [
|
||||
CommonModule,
|
||||
UserModule,
|
||||
UserBookNoteModule,
|
||||
UserBookStatusModule,
|
||||
AchievementModule,
|
||||
AnnotationModule,
|
||||
@@ -37,6 +40,7 @@ import { KoreaderStatsService } from './koreader-stats.service';
|
||||
DashboardModule,
|
||||
OpdsModule,
|
||||
PositionConverterModule,
|
||||
RecommendationModule,
|
||||
],
|
||||
controllers: [KoreaderController, KoreaderPluginController, KoreaderCatalogController],
|
||||
providers: [
|
||||
|
||||
@@ -11,5 +11,6 @@ import { RecommendationService } from './recommendation.service';
|
||||
imports: [BookModule, LibraryModule, EmbeddingModule],
|
||||
controllers: [RecommendationController],
|
||||
providers: [RecommendationService, RecommendationRepository],
|
||||
exports: [RecommendationService],
|
||||
})
|
||||
export class RecommendationModule {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RecommendationRepository } from './recommendation.repository';
|
||||
|
||||
type SelectStep = {
|
||||
terminal: 'where' | 'limit';
|
||||
terminal: 'where' | 'limit' | 'groupBy';
|
||||
result: unknown;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,10 @@ function makeDb(steps: SelectStep[]) {
|
||||
chain.innerJoin.mockReturnValue(chain);
|
||||
chain.leftJoin.mockReturnValue(chain);
|
||||
chain.orderBy.mockReturnValue(chain);
|
||||
chain.groupBy.mockReturnValue(chain);
|
||||
chain.groupBy.mockImplementation(() => {
|
||||
if (step.terminal === 'groupBy') return Promise.resolve(step.result);
|
||||
return chain;
|
||||
});
|
||||
chain.where.mockImplementation(() => {
|
||||
if (step.terminal === 'where') return Promise.resolve(step.result);
|
||||
return chain;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserBookNoteRepository } from './user-book-note.repository';
|
||||
import { UserBookNoteService } from './user-book-note.service';
|
||||
|
||||
@Module({
|
||||
providers: [UserBookNoteService, UserBookNoteRepository],
|
||||
exports: [UserBookNoteService],
|
||||
})
|
||||
export class UserBookNoteModule {}
|
||||
@@ -0,0 +1,98 @@
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
and: vi.fn((...conditions: unknown[]) => ({ op: 'and', conditions })),
|
||||
inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })),
|
||||
sql: Object.assign(
|
||||
vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
|
||||
{
|
||||
join: vi.fn((chunks: unknown[], separator: unknown) => ({ op: 'sql.join', chunks, separator })),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
import { UserBookNoteRepository } from './user-book-note.repository';
|
||||
|
||||
function makeDb() {
|
||||
const selectBuilder = {
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
limit: vi.fn(),
|
||||
};
|
||||
selectBuilder.from.mockReturnValue(selectBuilder);
|
||||
selectBuilder.where.mockReturnValue(selectBuilder);
|
||||
selectBuilder.limit.mockResolvedValue([]);
|
||||
|
||||
const insertBuilder = {
|
||||
values: vi.fn(),
|
||||
onConflictDoUpdate: vi.fn(),
|
||||
returning: vi.fn(),
|
||||
};
|
||||
insertBuilder.values.mockReturnValue(insertBuilder);
|
||||
insertBuilder.onConflictDoUpdate.mockReturnValue(insertBuilder);
|
||||
insertBuilder.returning.mockResolvedValue([]);
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue(selectBuilder),
|
||||
insert: vi.fn().mockReturnValue(insertBuilder),
|
||||
},
|
||||
selectBuilder,
|
||||
insertBuilder,
|
||||
};
|
||||
}
|
||||
|
||||
describe('UserBookNoteRepository', () => {
|
||||
it('findOne returns null when no note row exists', async () => {
|
||||
const { db } = makeDb();
|
||||
const repo = new UserBookNoteRepository(db as never);
|
||||
|
||||
await expect(repo.findOne(1, 10)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('findOne returns the matching row', async () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
const repo = new UserBookNoteRepository(db as never);
|
||||
const row = { userId: 1, bookId: 10, note: 'Great read', updatedAt: new Date('2026-01-01T00:00:00.000Z') };
|
||||
selectBuilder.limit.mockResolvedValue([row]);
|
||||
|
||||
await expect(repo.findOne(1, 10)).resolves.toEqual(row);
|
||||
});
|
||||
|
||||
it('findByBookIds short-circuits an empty id list without querying', async () => {
|
||||
const { db } = makeDb();
|
||||
const repo = new UserBookNoteRepository(db as never);
|
||||
|
||||
await expect(repo.findByBookIds(1, [])).resolves.toEqual([]);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('findByBookIds returns rows scoped to the given user and book ids', async () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
const repo = new UserBookNoteRepository(db as never);
|
||||
const rows = [{ userId: 1, bookId: 10, note: 'a', updatedAt: new Date() }];
|
||||
selectBuilder.where.mockResolvedValue(rows);
|
||||
|
||||
await expect(repo.findByBookIds(1, [10, 20])).resolves.toEqual(rows);
|
||||
});
|
||||
|
||||
it('upsert writes the note and returns the persisted row', async () => {
|
||||
const { db, insertBuilder } = makeDb();
|
||||
const repo = new UserBookNoteRepository(db as never);
|
||||
const updatedAt = new Date('2026-02-01T00:00:00.000Z');
|
||||
const row = { userId: 1, bookId: 10, note: 'Great read', updatedAt };
|
||||
insertBuilder.returning.mockResolvedValue([row]);
|
||||
|
||||
await expect(repo.upsert(1, 10, 'Great read', updatedAt)).resolves.toEqual(row);
|
||||
expect(insertBuilder.values).toHaveBeenCalledWith({ userId: 1, bookId: 10, note: 'Great read', updatedAt });
|
||||
expect(insertBuilder.onConflictDoUpdate).toHaveBeenCalledWith(expect.objectContaining({ set: { note: 'Great read', updatedAt } }));
|
||||
});
|
||||
|
||||
it('upsert supports clearing a note to null', async () => {
|
||||
const { db, insertBuilder } = makeDb();
|
||||
const repo = new UserBookNoteRepository(db as never);
|
||||
const updatedAt = new Date('2026-02-02T00:00:00.000Z');
|
||||
insertBuilder.returning.mockResolvedValue([{ userId: 1, bookId: 10, note: null, updatedAt }]);
|
||||
|
||||
await expect(repo.upsert(1, 10, null, updatedAt)).resolves.toEqual({ userId: 1, bookId: 10, note: null, updatedAt });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { userBookNotes } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class UserBookNoteRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findOne(userId: number, bookId: number) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(userBookNotes)
|
||||
.where(and(eq(userBookNotes.userId, userId), eq(userBookNotes.bookId, bookId)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findByBookIds(userId: number, bookIds: number[]) {
|
||||
if (bookIds.length === 0) return [];
|
||||
return this.db
|
||||
.select()
|
||||
.from(userBookNotes)
|
||||
.where(and(eq(userBookNotes.userId, userId), inArray(userBookNotes.bookId, bookIds)));
|
||||
}
|
||||
|
||||
async upsert(userId: number, bookId: number, note: string | null, updatedAt = new Date()) {
|
||||
const [row] = await this.db
|
||||
.insert(userBookNotes)
|
||||
.values({ userId, bookId, note, updatedAt })
|
||||
.onConflictDoUpdate({
|
||||
target: [userBookNotes.userId, userBookNotes.bookId],
|
||||
set: { note, updatedAt },
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { USER_BOOK_NOTE_MAX_LENGTH, UserBookNoteService } from './user-book-note.service';
|
||||
|
||||
describe('UserBookNoteService', () => {
|
||||
let repo: {
|
||||
findOne: ReturnType<typeof vi.fn>;
|
||||
findByBookIds: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let service: UserBookNoteService;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
findOne: vi.fn(),
|
||||
findByBookIds: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
};
|
||||
service = new UserBookNoteService(repo as never);
|
||||
});
|
||||
|
||||
it('normalizes blank notes to null and trims text', async () => {
|
||||
const updatedAt = new Date('2026-07-01T12:00:00.000Z');
|
||||
repo.upsert.mockResolvedValue({ userId: 1, bookId: 2, note: 'review', updatedAt });
|
||||
|
||||
await expect(service.setNote(1, 2, ' review ', updatedAt)).resolves.toEqual({
|
||||
note: 'review',
|
||||
updatedAt: '2026-07-01T12:00:00.000Z',
|
||||
});
|
||||
expect(repo.upsert).toHaveBeenCalledWith(1, 2, 'review', updatedAt);
|
||||
|
||||
repo.upsert.mockResolvedValue({ userId: 1, bookId: 2, note: null, updatedAt });
|
||||
await service.setNote(1, 2, ' ', updatedAt);
|
||||
expect(repo.upsert).toHaveBeenLastCalledWith(1, 2, null, updatedAt);
|
||||
});
|
||||
|
||||
it('caps notes at the sync contract limit', () => {
|
||||
const value = 'a'.repeat(USER_BOOK_NOTE_MAX_LENGTH + 10);
|
||||
|
||||
expect(service.normalizeNote(value)).toHaveLength(USER_BOOK_NOTE_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it('findOne maps the repository row to a dto and returns null when missing', async () => {
|
||||
const updatedAt = new Date('2026-07-02T00:00:00.000Z');
|
||||
repo.findOne.mockResolvedValue({ userId: 1, bookId: 2, note: 'review', updatedAt });
|
||||
|
||||
await expect(service.findOne(1, 2)).resolves.toEqual({ note: 'review', updatedAt: '2026-07-02T00:00:00.000Z' });
|
||||
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
await expect(service.findOne(1, 2)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('findRow returns the raw repository row', async () => {
|
||||
const updatedAt = new Date('2026-07-03T00:00:00.000Z');
|
||||
const row = { userId: 1, bookId: 2, note: 'review', updatedAt };
|
||||
repo.findOne.mockResolvedValue(row);
|
||||
|
||||
await expect(service.findRow(1, 2)).resolves.toEqual(row);
|
||||
});
|
||||
|
||||
it('findByBookIds maps rows by book id', async () => {
|
||||
const updatedAt = new Date('2026-07-04T00:00:00.000Z');
|
||||
repo.findByBookIds.mockResolvedValue([
|
||||
{ userId: 1, bookId: 2, note: 'a', updatedAt },
|
||||
{ userId: 1, bookId: 3, note: null, updatedAt },
|
||||
]);
|
||||
|
||||
const result = await service.findByBookIds(1, [2, 3]);
|
||||
|
||||
expect(repo.findByBookIds).toHaveBeenCalledWith(1, [2, 3]);
|
||||
expect(result.get(2)).toEqual({ note: 'a', updatedAt: '2026-07-04T00:00:00.000Z' });
|
||||
expect(result.get(3)).toEqual({ note: null, updatedAt: '2026-07-04T00:00:00.000Z' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { UserBookNoteRow } from '../../db/schema';
|
||||
import { UserBookNoteRepository } from './user-book-note.repository';
|
||||
|
||||
export const USER_BOOK_NOTE_MAX_LENGTH = 10000;
|
||||
|
||||
export interface UserBookNoteDto {
|
||||
note: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserBookNoteService {
|
||||
constructor(private readonly repo: UserBookNoteRepository) {}
|
||||
|
||||
async findOne(userId: number, bookId: number): Promise<UserBookNoteDto | null> {
|
||||
const row = await this.repo.findOne(userId, bookId);
|
||||
return row ? this.toDto(row) : null;
|
||||
}
|
||||
|
||||
async findRow(userId: number, bookId: number): Promise<UserBookNoteRow | null> {
|
||||
return this.repo.findOne(userId, bookId);
|
||||
}
|
||||
|
||||
async findByBookIds(userId: number, bookIds: number[]): Promise<Map<number, UserBookNoteDto>> {
|
||||
const rows = await this.repo.findByBookIds(userId, bookIds);
|
||||
const map = new Map<number, UserBookNoteDto>();
|
||||
for (const row of rows) {
|
||||
map.set(row.bookId, this.toDto(row));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async setNote(userId: number, bookId: number, rawNote: string | null | undefined, updatedAt = new Date()): Promise<UserBookNoteDto> {
|
||||
const note = this.normalizeNote(rawNote);
|
||||
const row = await this.repo.upsert(userId, bookId, note, updatedAt);
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
normalizeNote(rawNote: string | null | undefined): string | null {
|
||||
if (rawNote == null) return null;
|
||||
const trimmed = rawNote.trim();
|
||||
if (trimmed === '') return null;
|
||||
return trimmed.length > USER_BOOK_NOTE_MAX_LENGTH ? trimmed.slice(0, USER_BOOK_NOTE_MAX_LENGTH) : trimmed;
|
||||
}
|
||||
|
||||
private toDto(row: UserBookNoteRow): UserBookNoteDto {
|
||||
return {
|
||||
note: row.note ?? null,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user