mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(kobo): add manual KOReader book hash linking and unmatched tracking
When KOReader syncs progress or annotations, it identifies books by a document hash. BookOrbit normally resolves this automatically, but some books cannot be matched. This adds a tracking table for unmatched hashes and a UI to manually link them to existing library books. Closes #458
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Renders the plain-text SQL for a drizzle-orm `SQL` template (e.g. from `sql\`...\``)
|
||||
* so tests can assert on the generated query without a live database connection.
|
||||
*/
|
||||
export function sqlChunkText(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
|
||||
if (!Array.isArray(chunks)) return '';
|
||||
return chunks
|
||||
.map((chunk) => {
|
||||
if (!chunk || typeof chunk !== 'object') return '';
|
||||
const text = (chunk as { value?: unknown }).value;
|
||||
if (Array.isArray(text)) return text.join('');
|
||||
return sqlChunkText(chunk);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "koreader_book_hash_links" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"hash" varchar(32) NOT NULL,
|
||||
"book_file_id" integer NOT NULL,
|
||||
"koreader_title" varchar(500),
|
||||
"koreader_authors" text,
|
||||
"koreader_last_open" bigint,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "koreader_book_hash_links_hash_chk" CHECK ("koreader_book_hash_links"."hash" ~ '^[0-9a-f]{32}$')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "koreader_unmatched_books" (
|
||||
"user_id" integer NOT NULL,
|
||||
"hash" varchar(32) NOT NULL,
|
||||
"title" varchar(500),
|
||||
"authors" text,
|
||||
"last_open" bigint,
|
||||
"source" varchar(20) DEFAULT 'statistics' NOT NULL,
|
||||
"metadata_ambiguous" boolean DEFAULT false NOT NULL,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "koreader_unmatched_books_user_id_hash_pk" PRIMARY KEY("user_id","hash"),
|
||||
CONSTRAINT "koreader_unmatched_books_hash_chk" CHECK ("koreader_unmatched_books"."hash" ~ '^[0-9a-f]{32}$'),
|
||||
CONSTRAINT "koreader_unmatched_books_source_chk" CHECK ("koreader_unmatched_books"."source" in ('current_file', 'file', 'statistics'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "koreader_book_hash_links" ADD CONSTRAINT "koreader_book_hash_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "koreader_book_hash_links" ADD CONSTRAINT "koreader_book_hash_links_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "koreader_unmatched_books" ADD CONSTRAINT "koreader_unmatched_books_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "koreader_book_hash_links_user_hash_uidx" ON "koreader_book_hash_links" USING btree ("user_id","hash");--> statement-breakpoint
|
||||
CREATE INDEX "koreader_book_hash_links_user_updated_idx" ON "koreader_book_hash_links" USING btree ("user_id","updated_at" desc);--> statement-breakpoint
|
||||
CREATE INDEX "koreader_book_hash_links_book_file_id_idx" ON "koreader_book_hash_links" USING btree ("book_file_id");--> statement-breakpoint
|
||||
CREATE INDEX "koreader_unmatched_books_user_seen_idx" ON "koreader_unmatched_books" USING btree ("user_id","last_seen_at" desc);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -239,6 +239,13 @@
|
||||
"when": 1782775423934,
|
||||
"tag": "0033_community-rating-filter-indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1782943263105,
|
||||
"tag": "0034_koreader-manual-hash-links",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -102,6 +102,63 @@ export const bookFileHashHistory = pgTable(
|
||||
export type BookFileHashHistory = typeof bookFileHashHistory.$inferSelect;
|
||||
export type NewBookFileHashHistory = typeof bookFileHashHistory.$inferInsert;
|
||||
|
||||
export const koreaderBookHashLinks = pgTable(
|
||||
'koreader_book_hash_links',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
hash: varchar('hash', { length: 32 }).notNull(),
|
||||
bookFileId: integer('book_file_id')
|
||||
.notNull()
|
||||
.references(() => bookFiles.id, { onDelete: 'cascade' }),
|
||||
koreaderTitle: varchar('koreader_title', { length: 500 }),
|
||||
koreaderAuthors: text('koreader_authors'),
|
||||
koreaderLastOpen: bigint('koreader_last_open', { mode: 'number' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('koreader_book_hash_links_user_hash_uidx').on(t.userId, t.hash),
|
||||
index('koreader_book_hash_links_user_updated_idx').on(t.userId, sql`${t.updatedAt} desc`),
|
||||
index('koreader_book_hash_links_book_file_id_idx').on(t.bookFileId),
|
||||
check('koreader_book_hash_links_hash_chk', sql`${t.hash} ~ '^[0-9a-f]{32}$'`),
|
||||
],
|
||||
);
|
||||
|
||||
export type KoreaderBookHashLink = typeof koreaderBookHashLinks.$inferSelect;
|
||||
export type NewKoreaderBookHashLink = typeof koreaderBookHashLinks.$inferInsert;
|
||||
|
||||
export const koreaderUnmatchedBooks = pgTable(
|
||||
'koreader_unmatched_books',
|
||||
{
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
hash: varchar('hash', { length: 32 }).notNull(),
|
||||
title: varchar('title', { length: 500 }),
|
||||
authors: text('authors'),
|
||||
lastOpen: bigint('last_open', { mode: 'number' }),
|
||||
source: varchar('source', { length: 20 }).notNull().default('statistics'),
|
||||
metadataAmbiguous: boolean('metadata_ambiguous').notNull().default(false),
|
||||
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.userId, t.hash] }),
|
||||
index('koreader_unmatched_books_user_seen_idx').on(t.userId, sql`${t.lastSeenAt} desc`),
|
||||
check('koreader_unmatched_books_hash_chk', sql`${t.hash} ~ '^[0-9a-f]{32}$'`),
|
||||
check('koreader_unmatched_books_source_chk', sql`${t.source} in ('current_file', 'file', 'statistics')`),
|
||||
],
|
||||
);
|
||||
|
||||
export type KoreaderUnmatchedBook = typeof koreaderUnmatchedBooks.$inferSelect;
|
||||
export type NewKoreaderUnmatchedBook = typeof koreaderUnmatchedBooks.$inferInsert;
|
||||
|
||||
export const bookFileChapters = pgTable(
|
||||
'book_file_chapters',
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './koreader-catalog-query.dto';
|
||||
export * from './koreader-exchange.dto';
|
||||
export * from './koreader-link.dto';
|
||||
export * from './koreader-package.dto';
|
||||
export * from './koreader-plugin.dto';
|
||||
export * from './koreader-progress.dto';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class LinkKoreaderUnmatchedBookDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
bookId!: number;
|
||||
}
|
||||
|
||||
export class UpdateKoreaderManualHashLinkDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
bookId!: number;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
@@ -42,6 +43,35 @@ export class PluginDeviceDto {
|
||||
deviceTime?: string;
|
||||
}
|
||||
|
||||
export class MatchCheckBookDto {
|
||||
@IsString()
|
||||
@Matches(MD5_HEX)
|
||||
hash!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
authors?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
lastOpen?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['current_file', 'file', 'statistics'])
|
||||
source?: 'current_file' | 'file' | 'statistics';
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
metadataAmbiguous?: boolean;
|
||||
}
|
||||
|
||||
export class MatchCheckDto extends PluginDeviceDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@@ -49,6 +79,13 @@ export class MatchCheckDto extends PluginDeviceDto {
|
||||
@IsString({ each: true })
|
||||
@Matches(MD5_HEX, { each: true })
|
||||
hashes!: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MatchCheckBookDto)
|
||||
books?: MatchCheckBookDto[];
|
||||
}
|
||||
|
||||
export class PageStatEventDto {
|
||||
|
||||
@@ -98,7 +98,7 @@ export class KoreaderAnnotationExchangeService {
|
||||
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
const deviceClockOffsetMs = this.deviceClockOffsetMs(dto.deviceTime);
|
||||
|
||||
const results: ExchangeBookResult[] = [];
|
||||
@@ -135,7 +135,7 @@ export class KoreaderAnnotationExchangeService {
|
||||
try {
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
|
||||
const results: { hash: string; acked: number }[] = [];
|
||||
const unmatched: string[] = [];
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { KoreaderHashLinkService } from './koreader-hash-link.service';
|
||||
import { KoreaderRepository } from './koreader.repository';
|
||||
|
||||
const HASH_A = 'a'.repeat(32);
|
||||
const HASH_B = 'b'.repeat(32);
|
||||
|
||||
describe('KoreaderHashLinkService', () => {
|
||||
let service: KoreaderHashLinkService;
|
||||
let mockRepo: {
|
||||
getAccessibleLibraryIds: ReturnType<typeof vi.fn>;
|
||||
resolveBookFileByHash: ReturnType<typeof vi.fn>;
|
||||
resolveBookFilesByHashes: ReturnType<typeof vi.fn>;
|
||||
listUnmatchedBooks: ReturnType<typeof vi.fn>;
|
||||
getUnmatchedBook: ReturnType<typeof vi.fn>;
|
||||
clearUnmatchedBooks: ReturnType<typeof vi.fn>;
|
||||
listBookHashLinks: ReturnType<typeof vi.fn>;
|
||||
upsertBookHashLink: ReturnType<typeof vi.fn>;
|
||||
getBookHashLink: ReturnType<typeof vi.fn>;
|
||||
deleteBookHashLink: ReturnType<typeof vi.fn>;
|
||||
upsertUnmatchedBooks: ReturnType<typeof vi.fn>;
|
||||
findBookFileIdByBookId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockBookService: {
|
||||
verifyBookAccess: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const user = { id: 7, settings: {}, contentFilters: null } as never;
|
||||
const firstSeenAt = new Date('2026-06-01T10:00:00.000Z');
|
||||
const lastSeenAt = new Date('2026-06-02T11:00:00.000Z');
|
||||
|
||||
function makeUnmatchedRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
userId: 7,
|
||||
hash: HASH_A,
|
||||
title: 'Stats title',
|
||||
authors: 'Stats author',
|
||||
lastOpen: 100,
|
||||
source: 'file',
|
||||
metadataAmbiguous: false,
|
||||
firstSeenAt,
|
||||
lastSeenAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockRepo = {
|
||||
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1, 2]),
|
||||
resolveBookFileByHash: vi.fn(),
|
||||
resolveBookFilesByHashes: vi.fn().mockResolvedValue(new Map()),
|
||||
listUnmatchedBooks: vi.fn().mockResolvedValue([]),
|
||||
getUnmatchedBook: vi.fn().mockResolvedValue(null),
|
||||
clearUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
|
||||
listBookHashLinks: vi.fn().mockResolvedValue([]),
|
||||
upsertBookHashLink: vi.fn().mockResolvedValue(undefined),
|
||||
getBookHashLink: vi.fn().mockResolvedValue(null),
|
||||
deleteBookHashLink: vi.fn().mockResolvedValue(null),
|
||||
upsertUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
|
||||
findBookFileIdByBookId: vi.fn(),
|
||||
};
|
||||
|
||||
mockBookService = {
|
||||
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
service = new KoreaderHashLinkService(mockRepo as unknown as KoreaderRepository, mockBookService as never);
|
||||
});
|
||||
|
||||
describe('listUnmatchedBooks', () => {
|
||||
it('returns an empty array without further lookups when there are no unmatched rows', async () => {
|
||||
mockRepo.listUnmatchedBooks.mockResolvedValue([]);
|
||||
|
||||
await expect(service.listUnmatchedBooks(user)).resolves.toEqual([]);
|
||||
|
||||
expect(mockRepo.getAccessibleLibraryIds).not.toHaveBeenCalled();
|
||||
expect(mockRepo.resolveBookFilesByHashes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists unmatched books and clears rows that now resolve', async () => {
|
||||
mockRepo.listUnmatchedBooks.mockResolvedValue([
|
||||
{
|
||||
userId: 7,
|
||||
hash: HASH_A,
|
||||
title: 'Already matched',
|
||||
authors: 'Author A',
|
||||
lastOpen: 100,
|
||||
source: 'file',
|
||||
metadataAmbiguous: false,
|
||||
firstSeenAt,
|
||||
lastSeenAt,
|
||||
},
|
||||
{
|
||||
userId: 7,
|
||||
hash: HASH_B,
|
||||
title: 'Needs link',
|
||||
authors: null,
|
||||
lastOpen: null,
|
||||
source: 'file',
|
||||
metadataAmbiguous: false,
|
||||
firstSeenAt,
|
||||
lastSeenAt,
|
||||
},
|
||||
]);
|
||||
mockRepo.resolveBookFilesByHashes.mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]]));
|
||||
|
||||
await expect(service.listUnmatchedBooks(user)).resolves.toEqual([
|
||||
{
|
||||
hash: HASH_B,
|
||||
title: 'Needs link',
|
||||
authors: null,
|
||||
lastOpen: null,
|
||||
firstSeenAt: '2026-06-01T10:00:00.000Z',
|
||||
lastSeenAt: '2026-06-02T11:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
expect(mockRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A, HASH_B], [1, 2], 7);
|
||||
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
|
||||
});
|
||||
|
||||
it('does not call clearUnmatchedBooks when nothing resolves', async () => {
|
||||
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow()]);
|
||||
mockRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
|
||||
|
||||
await expect(service.listUnmatchedBooks(user)).resolves.toEqual([
|
||||
{
|
||||
hash: HASH_A,
|
||||
title: 'Stats title',
|
||||
authors: 'Stats author',
|
||||
lastOpen: 100,
|
||||
firstSeenAt: '2026-06-01T10:00:00.000Z',
|
||||
lastSeenAt: '2026-06-02T11:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkUnmatchedBook', () => {
|
||||
it('links an unmatched hash to an accessible book file', async () => {
|
||||
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
|
||||
|
||||
await expect(service.linkUnmatchedBook(user, HASH_A.toUpperCase(), 55)).resolves.toEqual({ hash: HASH_A, bookId: 55, bookFileId: 44 });
|
||||
|
||||
expect(mockBookService.verifyBookAccess).toHaveBeenCalledWith(55, user);
|
||||
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith(HASH_A, [1, 2]);
|
||||
expect(mockRepo.getBookHashLink).toHaveBeenCalledWith(7, HASH_A);
|
||||
expect(mockRepo.upsertBookHashLink).toHaveBeenCalledWith(7, HASH_A, 44, {
|
||||
title: 'Stats title',
|
||||
authors: 'Stats author',
|
||||
lastOpen: 100,
|
||||
});
|
||||
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
|
||||
});
|
||||
|
||||
it('does not create a manual link when the hash already resolves to the same file intrinsically', async () => {
|
||||
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 44, bookId: 55, libraryId: 1 });
|
||||
|
||||
await service.linkUnmatchedBook(user, HASH_A, 55);
|
||||
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
|
||||
});
|
||||
|
||||
it('rejects linking historical or ambiguous unmatched rows', async () => {
|
||||
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow({ source: 'statistics' }));
|
||||
|
||||
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(mockBookService.verifyBookAccess).not.toHaveBeenCalled();
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid hashes before checking book access', async () => {
|
||||
await expect(service.linkUnmatchedBook(user, 'not-a-md5', 55)).rejects.toThrow(BadRequestException);
|
||||
|
||||
expect(mockBookService.verifyBookAccess).not.toHaveBeenCalled();
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects linking when the target book has no primary file', async () => {
|
||||
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(BadRequestException);
|
||||
|
||||
expect(mockRepo.resolveBookFileByHash).not.toHaveBeenCalled();
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects links when the hash already belongs to a different accessible book file', async () => {
|
||||
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 99, bookId: 88, libraryId: 1 });
|
||||
|
||||
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(ConflictException);
|
||||
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects linking when the hash is already manually linked to a different file', async () => {
|
||||
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
|
||||
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 99 });
|
||||
|
||||
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(ConflictException);
|
||||
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listManualHashLinks', () => {
|
||||
it('lists manual hash links with BookOrbit and KOReader metadata', async () => {
|
||||
const createdAt = new Date('2026-06-01T10:00:00.000Z');
|
||||
const updatedAt = new Date('2026-06-02T11:00:00.000Z');
|
||||
mockRepo.listBookHashLinks.mockResolvedValue([
|
||||
{
|
||||
hash: HASH_A,
|
||||
bookId: 55,
|
||||
bookFileId: 44,
|
||||
bookTitle: 'BookOrbit Title',
|
||||
bookAuthors: ['BookOrbit Author'],
|
||||
koreaderTitle: 'KOReader Title',
|
||||
koreaderAuthors: 'KOReader Author',
|
||||
koreaderLastOpen: 100,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.listManualHashLinks(user)).resolves.toEqual([
|
||||
{
|
||||
hash: HASH_A,
|
||||
bookId: 55,
|
||||
bookFileId: 44,
|
||||
bookTitle: 'BookOrbit Title',
|
||||
bookAuthors: ['BookOrbit Author'],
|
||||
koreaderTitle: 'KOReader Title',
|
||||
koreaderAuthors: 'KOReader Author',
|
||||
koreaderLastOpen: 100,
|
||||
createdAt: '2026-06-01T10:00:00.000Z',
|
||||
updatedAt: '2026-06-02T11:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
expect(mockRepo.listBookHashLinks).toHaveBeenCalledWith(7, 100, [1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relinkManualHashLink', () => {
|
||||
it('relinks an existing manual hash link to a different accessible book file', async () => {
|
||||
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 44 });
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(66);
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
|
||||
|
||||
await expect(service.relinkManualHashLink(user, HASH_A, 77)).resolves.toEqual({ hash: HASH_A, bookId: 77, bookFileId: 66 });
|
||||
|
||||
expect(mockBookService.verifyBookAccess).toHaveBeenCalledWith(77, user);
|
||||
expect(mockRepo.upsertBookHashLink).toHaveBeenCalledWith(7, HASH_A, 66);
|
||||
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
|
||||
});
|
||||
|
||||
it('rejects relinking when no manual link exists', async () => {
|
||||
mockRepo.getBookHashLink.mockResolvedValue(null);
|
||||
|
||||
await expect(service.relinkManualHashLink(user, HASH_A, 77)).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(mockBookService.verifyBookAccess).not.toHaveBeenCalled();
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects relinking when the target book has no primary file', async () => {
|
||||
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 44 });
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.relinkManualHashLink(user, HASH_A, 77)).rejects.toThrow(BadRequestException);
|
||||
|
||||
expect(mockRepo.resolveBookFileByHash).not.toHaveBeenCalled();
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects relinking when the hash already matches a different book intrinsically', async () => {
|
||||
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 44 });
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(66);
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 99, bookId: 88, libraryId: 1 });
|
||||
|
||||
await expect(service.relinkManualHashLink(user, HASH_A, 77)).rejects.toThrow(ConflictException);
|
||||
|
||||
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkManualHashLink', () => {
|
||||
it('unlinks a manual hash link and restores it as unmatched when no intrinsic match exists', async () => {
|
||||
mockRepo.deleteBookHashLink.mockResolvedValue({
|
||||
hash: HASH_A,
|
||||
bookFileId: 44,
|
||||
koreaderTitle: 'KOReader Title',
|
||||
koreaderAuthors: 'KOReader Author',
|
||||
koreaderLastOpen: 100,
|
||||
});
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
|
||||
|
||||
await expect(service.unlinkManualHashLink(user, HASH_A)).resolves.toEqual({ hash: HASH_A });
|
||||
|
||||
expect(mockRepo.deleteBookHashLink).toHaveBeenCalledWith(7, HASH_A);
|
||||
expect(mockRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
|
||||
{ hash: HASH_A, title: 'KOReader Title', authors: 'KOReader Author', lastOpen: 100, source: 'file', metadataAmbiguous: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not restore the hash as unmatched when it still resolves intrinsically', async () => {
|
||||
mockRepo.deleteBookHashLink.mockResolvedValue({
|
||||
hash: HASH_A,
|
||||
bookFileId: 44,
|
||||
koreaderTitle: 'KOReader Title',
|
||||
koreaderAuthors: 'KOReader Author',
|
||||
koreaderLastOpen: 100,
|
||||
});
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 44, bookId: 55, libraryId: 1 });
|
||||
|
||||
await expect(service.unlinkManualHashLink(user, HASH_A)).resolves.toEqual({ hash: HASH_A });
|
||||
|
||||
expect(mockRepo.upsertUnmatchedBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unlinking when no manual link exists', async () => {
|
||||
mockRepo.deleteBookHashLink.mockResolvedValue(null);
|
||||
|
||||
await expect(service.unlinkManualHashLink(user, HASH_A)).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(mockRepo.upsertUnmatchedBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
KoreaderManualHashLink,
|
||||
KoreaderUnmatchedBook,
|
||||
LinkKoreaderUnmatchedBookResult,
|
||||
UnlinkKoreaderManualHashLinkResult,
|
||||
} from '@bookorbit/types';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { KoreaderRepository } from './koreader.repository';
|
||||
|
||||
const MD5_HEX = /^[0-9a-f]{32}$/;
|
||||
const UNMATCHED_BOOK_LIMIT = 100;
|
||||
const MANUAL_HASH_LINK_LIMIT = 100;
|
||||
const HASH_LINK_CREATE_EVENT = 'koreader.hash_link_create';
|
||||
const HASH_LINK_UPDATE_EVENT = 'koreader.hash_link_update';
|
||||
const HASH_LINK_DELETE_EVENT = 'koreader.hash_link_delete';
|
||||
|
||||
/**
|
||||
* Manages the KOReader "unmatched book" queue and the manual hash links used
|
||||
* to resolve hashes that BookOrbit cannot match intrinsically (via file hash
|
||||
* or hash history). Split out of KoreaderService to keep that class focused
|
||||
* on credentials and progress sync.
|
||||
*/
|
||||
@Injectable()
|
||||
export class KoreaderHashLinkService {
|
||||
private readonly logger = new Logger(KoreaderHashLinkService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repo: KoreaderRepository,
|
||||
private readonly bookService: BookService,
|
||||
) {}
|
||||
|
||||
async listUnmatchedBooks(user: RequestUser): Promise<KoreaderUnmatchedBook[]> {
|
||||
const rows = await this.repo.listUnmatchedBooks(user.id, UNMATCHED_BOOK_LIMIT);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
|
||||
const hashes = rows.map((row) => row.hash);
|
||||
const resolved = await this.repo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
if (resolved.size > 0) {
|
||||
await this.repo.clearUnmatchedBooks(user.id, [...resolved.keys()]);
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter((row) => !resolved.has(row.hash))
|
||||
.map((row) => ({
|
||||
hash: row.hash,
|
||||
title: row.title ?? null,
|
||||
authors: row.authors ?? null,
|
||||
lastOpen: row.lastOpen ?? null,
|
||||
firstSeenAt: row.firstSeenAt.toISOString(),
|
||||
lastSeenAt: row.lastSeenAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async linkUnmatchedBook(user: RequestUser, hash: string, bookId: number): Promise<LinkKoreaderUnmatchedBookResult> {
|
||||
const startedAtMs = Date.now();
|
||||
const normalizedHash = this.normalizeKoreaderHash(hash);
|
||||
this.logger.log(
|
||||
`[${HASH_LINK_CREATE_EVENT}] [start] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} - hash link create started`,
|
||||
);
|
||||
|
||||
const unmatched = await this.repo.getUnmatchedBook(user.id, normalizedHash);
|
||||
if (!unmatched || !isLinkableUnmatchedBook(unmatched)) throw new NotFoundException('KOReader unmatched book not found');
|
||||
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
const bookFileId = await this.repo.findBookFileIdByBookId(bookId);
|
||||
if (!bookFileId) throw new BadRequestException('Book has no file to link');
|
||||
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
|
||||
const existingIntrinsicMatch = await this.repo.resolveBookFileByHash(normalizedHash, accessibleLibraryIds);
|
||||
if (existingIntrinsicMatch && existingIntrinsicMatch.id !== bookFileId) {
|
||||
throw new ConflictException('KOReader hash already matches a different book');
|
||||
}
|
||||
|
||||
const existingLink = await this.repo.getBookHashLink(user.id, normalizedHash);
|
||||
if (existingLink && existingLink.bookFileId !== bookFileId) {
|
||||
throw new ConflictException('KOReader hash is already linked to a different book');
|
||||
}
|
||||
|
||||
if (!existingIntrinsicMatch) {
|
||||
await this.repo.upsertBookHashLink(user.id, normalizedHash, bookFileId, {
|
||||
title: unmatched?.title ?? null,
|
||||
authors: unmatched?.authors ?? null,
|
||||
lastOpen: unmatched?.lastOpen ?? null,
|
||||
});
|
||||
}
|
||||
await this.repo.clearUnmatchedBooks(user.id, [normalizedHash]);
|
||||
|
||||
this.logger.log(
|
||||
`[${HASH_LINK_CREATE_EVENT}] [end] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} bookFileId=${bookFileId} durationMs=${Date.now() - startedAtMs} - hash link create completed`,
|
||||
);
|
||||
return { hash: normalizedHash, bookId, bookFileId };
|
||||
}
|
||||
|
||||
async listManualHashLinks(user: RequestUser): Promise<KoreaderManualHashLink[]> {
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
|
||||
const rows = await this.repo.listBookHashLinks(user.id, MANUAL_HASH_LINK_LIMIT, accessibleLibraryIds);
|
||||
|
||||
return rows.map((row) => ({
|
||||
hash: row.hash,
|
||||
bookId: row.bookId,
|
||||
bookFileId: row.bookFileId,
|
||||
bookTitle: row.bookTitle ?? null,
|
||||
bookAuthors: row.bookAuthors,
|
||||
koreaderTitle: row.koreaderTitle ?? null,
|
||||
koreaderAuthors: row.koreaderAuthors ?? null,
|
||||
koreaderLastOpen: row.koreaderLastOpen ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async relinkManualHashLink(user: RequestUser, hash: string, bookId: number): Promise<LinkKoreaderUnmatchedBookResult> {
|
||||
const startedAtMs = Date.now();
|
||||
const normalizedHash = this.normalizeKoreaderHash(hash);
|
||||
this.logger.log(
|
||||
`[${HASH_LINK_UPDATE_EVENT}] [start] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} - hash link update started`,
|
||||
);
|
||||
|
||||
const existingLink = await this.repo.getBookHashLink(user.id, normalizedHash);
|
||||
if (!existingLink) throw new NotFoundException('KOReader manual link not found');
|
||||
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
const bookFileId = await this.repo.findBookFileIdByBookId(bookId);
|
||||
if (!bookFileId) throw new BadRequestException('Book has no file to link');
|
||||
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
|
||||
const existingIntrinsicMatch = await this.repo.resolveBookFileByHash(normalizedHash, accessibleLibraryIds);
|
||||
if (existingIntrinsicMatch && existingIntrinsicMatch.id !== bookFileId) {
|
||||
throw new ConflictException('KOReader hash already matches a different book');
|
||||
}
|
||||
|
||||
await this.repo.upsertBookHashLink(user.id, normalizedHash, bookFileId);
|
||||
await this.repo.clearUnmatchedBooks(user.id, [normalizedHash]);
|
||||
|
||||
this.logger.log(
|
||||
`[${HASH_LINK_UPDATE_EVENT}] [end] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} bookFileId=${bookFileId} durationMs=${Date.now() - startedAtMs} - hash link update completed`,
|
||||
);
|
||||
return { hash: normalizedHash, bookId, bookFileId };
|
||||
}
|
||||
|
||||
async unlinkManualHashLink(user: RequestUser, hash: string): Promise<UnlinkKoreaderManualHashLinkResult> {
|
||||
const startedAtMs = Date.now();
|
||||
const normalizedHash = this.normalizeKoreaderHash(hash);
|
||||
this.logger.log(`[${HASH_LINK_DELETE_EVENT}] [start] userId=${user.id} hash=${normalizedHash.slice(0, 8)} - hash link delete started`);
|
||||
|
||||
const deleted = await this.repo.deleteBookHashLink(user.id, normalizedHash);
|
||||
if (!deleted) throw new NotFoundException('KOReader manual link not found');
|
||||
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
|
||||
const existingIntrinsicMatch = await this.repo.resolveBookFileByHash(normalizedHash, accessibleLibraryIds);
|
||||
const restoredAsUnmatched = !existingIntrinsicMatch;
|
||||
if (restoredAsUnmatched) {
|
||||
await this.repo.upsertUnmatchedBooks(user.id, [
|
||||
{
|
||||
hash: normalizedHash,
|
||||
title: deleted.koreaderTitle,
|
||||
authors: deleted.koreaderAuthors,
|
||||
lastOpen: deleted.koreaderLastOpen,
|
||||
source: 'file',
|
||||
metadataAmbiguous: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[${HASH_LINK_DELETE_EVENT}] [end] userId=${user.id} hash=${normalizedHash.slice(0, 8)} durationMs=${Date.now() - startedAtMs} restoredAsUnmatched=${restoredAsUnmatched} - hash link delete completed`,
|
||||
);
|
||||
return { hash: normalizedHash };
|
||||
}
|
||||
|
||||
private normalizeKoreaderHash(hash: string): string {
|
||||
const normalized = hash.trim().toLowerCase();
|
||||
if (!MD5_HEX.test(normalized)) throw new BadRequestException('Invalid KOReader hash');
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function isLinkableUnmatchedBook(row: { source?: string | null; metadataAmbiguous?: boolean | null } | null): boolean {
|
||||
return !!row && (row.source === 'current_file' || row.source === 'file') && row.metadataAmbiguous === false;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export class KoreaderPluginAnnotationService {
|
||||
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
|
||||
const results: { hash: string; upserted: number }[] = [];
|
||||
const unmatched: string[] = [];
|
||||
|
||||
@@ -204,4 +204,57 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(downloadIcon).toContain('<svg');
|
||||
expect(downloadIcon).toContain('M24 7v24');
|
||||
});
|
||||
|
||||
it('revalidates current-book matches and sends metadata when checking live books', async () => {
|
||||
const bookSync = await readPluginFile('bookorbit_book_sync.lua');
|
||||
const statsReader = await readPluginFile('bookorbit_stats_reader.lua');
|
||||
|
||||
expect(statsReader).toContain('function BookOrbitStatsReader.getBook(md5)');
|
||||
expect(statsReader).toContain('if title == "" then title = nil end');
|
||||
expect(statsReader).toContain('if authors == "" then authors = nil end');
|
||||
expect(statsReader).toContain('if title then entry.title = title end');
|
||||
expect(statsReader).toContain('if authors then entry.authors = authors end');
|
||||
expect(bookSync).toContain('local metadata = BookOrbitStatsReader.getBook(digest) or {}');
|
||||
expect(bookSync).toContain('local stats_ambiguous = metadata.metadata_ambiguous == true');
|
||||
expect(bookSync).toContain('title = stats_ambiguous and titleFromFile(file) or (metadata.title or titleFromFile(file))');
|
||||
expect(bookSync).toContain('authors = stats_ambiguous and nil or metadata.authors');
|
||||
expect(bookSync).toContain('last_open = metadata.last_open or ts');
|
||||
expect(bookSync).toContain('metadata_ambiguous = false');
|
||||
expect(bookSync).toContain('stats_metadata_ambiguous = stats_ambiguous');
|
||||
expect(bookSync).toContain('local body, err = ctx.client:matchCheck({ ctx.snap.digest }, {');
|
||||
expect(bookSync).toContain('title = ctx.snap.title');
|
||||
expect(bookSync).toContain('authors = ctx.snap.authors');
|
||||
expect(bookSync).toContain('last_open = ctx.snap.last_open');
|
||||
expect(bookSync).toContain('source = "current_file"');
|
||||
expect(bookSync).toContain('metadata_ambiguous = ctx.snap.metadata_ambiguous');
|
||||
expect(bookSync).toContain('if ctx.snap.stats_metadata_ambiguous then');
|
||||
expect(bookSync).toContain('ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file)');
|
||||
expect(bookSync).not.toContain(
|
||||
'if book then\n if ctx.snap.file and not book.file then\n book.file = ctx.snap.file\n end\n return step(ctx, stepStats)\n end',
|
||||
);
|
||||
});
|
||||
|
||||
it('rechecks matched local hashes during full-library revalidation', async () => {
|
||||
const sweep = await readPluginFile('bookorbit_sweep.lua');
|
||||
|
||||
expect(sweep).toContain('if ctx.full_recheck then\n queue(md5)\n elseif not ctx.state:getBook(md5) then');
|
||||
expect(sweep).toContain('for md5 in pairs(ctx.state.books) do\n queue(md5)\n end');
|
||||
});
|
||||
|
||||
it('marks unmatched match-check candidates by source and ambiguity', async () => {
|
||||
const api = await readPluginFile('bookorbit_api.lua');
|
||||
const sweep = await readPluginFile('bookorbit_sweep.lua');
|
||||
const statsReader = await readPluginFile('bookorbit_stats_reader.lua');
|
||||
|
||||
expect(api).toContain('source = cand.source');
|
||||
expect(api).toContain('metadataAmbiguous = cand.metadata_ambiguous');
|
||||
expect(statsReader).toContain('entry.metadata_ambiguous = (entry._variant_count or 0) > 1');
|
||||
expect(sweep).toContain('source = "statistics"');
|
||||
expect(sweep).toContain('metadata_ambiguous = entry.metadata_ambiguous');
|
||||
expect(sweep).toContain('stats_metadata_ambiguous = entry.metadata_ambiguous');
|
||||
expect(sweep).toContain('local file_exists = lfs.attributes(file, "mode") == "file"');
|
||||
expect(sweep).toContain('cand.source = "file"');
|
||||
expect(sweep).toContain('cand.metadata_ambiguous = false');
|
||||
expect(sweep).toContain('if cand.stat_ids and not cand.stats_metadata_ambiguous and ctx.state:getBook(md5) then');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sqlChunkText } from '../../common/test-utils/sql-chunk-text';
|
||||
import { KoreaderPluginRepository } from './koreader-plugin.repository';
|
||||
|
||||
function makeQueryChain(result: unknown) {
|
||||
const chain: Record<string, unknown> = {
|
||||
then(resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) {
|
||||
return Promise.resolve(result).then(resolve, reject);
|
||||
},
|
||||
};
|
||||
chain.from = vi.fn().mockReturnValue(chain);
|
||||
chain.where = vi.fn().mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
execute: vi.fn(),
|
||||
select: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KoreaderPluginRepository', () => {
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
let repo: KoreaderPluginRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
repo = new KoreaderPluginRepository(db as never);
|
||||
});
|
||||
|
||||
describe('getPluginTotals', () => {
|
||||
it('maps aggregate counts including linkable unmatched books', async () => {
|
||||
db.execute.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
matched_books: '2',
|
||||
page_stat_events: '10',
|
||||
annotations: '4',
|
||||
trashed_annotations: '1',
|
||||
pending_deletes: '3',
|
||||
failed_positions: '5',
|
||||
unmatched_books: '6',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(repo.getPluginTotals(7)).resolves.toEqual({
|
||||
matchedBooks: 2,
|
||||
pageStatEvents: 10,
|
||||
annotations: 4,
|
||||
trashedAnnotations: 1,
|
||||
pendingDeletes: 3,
|
||||
failedPositions: 5,
|
||||
unmatchedBooks: 6,
|
||||
});
|
||||
|
||||
const sqlText = sqlChunkText(db.execute.mock.calls[0]![0]).replace(/\s+/g, ' ');
|
||||
expect(sqlText).toContain('from koreader_unmatched_books');
|
||||
expect(sqlText).toContain("source in ('current_file', 'file')");
|
||||
expect(sqlText).toContain('metadata_ambiguous = false');
|
||||
});
|
||||
|
||||
it('defaults missing aggregate rows to zeroes', async () => {
|
||||
db.execute.mockResolvedValue({ rows: [] });
|
||||
|
||||
await expect(repo.getPluginTotals(7)).resolves.toEqual({
|
||||
matchedBooks: 0,
|
||||
pageStatEvents: 0,
|
||||
annotations: 0,
|
||||
trashedAnnotations: 0,
|
||||
pendingDeletes: 0,
|
||||
failedPositions: 0,
|
||||
unmatchedBooks: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHashLinkVersion', () => {
|
||||
it('returns manual link count and newest update timestamp', async () => {
|
||||
db.select.mockReturnValue(makeQueryChain([{ count: '3', maxTs: '2026-06-02T10:00:00.000Z' }]));
|
||||
|
||||
await expect(repo.getHashLinkVersion(7)).resolves.toEqual({
|
||||
count: 3,
|
||||
maxTs: new Date('2026-06-02T10:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty version when no link rows exist', async () => {
|
||||
db.select.mockReturnValue(makeQueryChain([]));
|
||||
|
||||
await expect(repo.getHashLinkVersion(7)).resolves.toEqual({ count: 0, maxTs: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -348,6 +348,7 @@ export class KoreaderPluginRepository {
|
||||
trashedAnnotations: number;
|
||||
pendingDeletes: number;
|
||||
failedPositions: number;
|
||||
unmatchedBooks: number;
|
||||
}> {
|
||||
const result = await this.db.execute<{
|
||||
matched_books: string | number;
|
||||
@@ -356,6 +357,7 @@ export class KoreaderPluginRepository {
|
||||
trashed_annotations: string | number;
|
||||
pending_deletes: string | number;
|
||||
failed_positions: string | number;
|
||||
unmatched_books: string | number;
|
||||
}>(sql`
|
||||
select
|
||||
(select count(distinct t.book_file_id) from (
|
||||
@@ -373,7 +375,11 @@ export class KoreaderPluginRepository {
|
||||
where s.user_id = ${userId} and a.deleted_at is not null and s.delete_acked_at is null) as pending_deletes,
|
||||
(select count(*) from annotation_positions ap
|
||||
join annotations a on a.id = ap.annotation_id
|
||||
where ap.user_id = ${userId} and a.deleted_at is null and ap.status = 'failed') as failed_positions
|
||||
where ap.user_id = ${userId} and a.deleted_at is null and ap.status = 'failed') as failed_positions,
|
||||
(select count(*) from koreader_unmatched_books
|
||||
where user_id = ${userId}
|
||||
and source in ('current_file', 'file')
|
||||
and metadata_ambiguous = false) as unmatched_books
|
||||
`);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
@@ -383,6 +389,7 @@ export class KoreaderPluginRepository {
|
||||
trashedAnnotations: Number(row?.trashed_annotations ?? 0),
|
||||
pendingDeletes: Number(row?.pending_deletes ?? 0),
|
||||
failedPositions: Number(row?.failed_positions ?? 0),
|
||||
unmatchedBooks: Number(row?.unmatched_books ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -398,4 +405,16 @@ export class KoreaderPluginRepository {
|
||||
|
||||
return row?.maxTs ? new Date(row.maxTs) : null;
|
||||
}
|
||||
|
||||
async getHashLinkVersion(userId: number): Promise<{ count: number; maxTs: Date | null }> {
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
count: sql<number | string>`count(*)`,
|
||||
maxTs: sql<Date | string | null>`max(${schema.koreaderBookHashLinks.updatedAt})`,
|
||||
})
|
||||
.from(schema.koreaderBookHashLinks)
|
||||
.where(eq(schema.koreaderBookHashLinks.userId, userId));
|
||||
|
||||
return { count: Number(row?.count ?? 0), maxTs: row?.maxTs ? new Date(row.maxTs) : null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('KoreaderPluginService', () => {
|
||||
let koreaderRepo: {
|
||||
getAccessibleLibraryIds: ReturnType<typeof vi.fn>;
|
||||
resolveBookFilesByHashes: ReturnType<typeof vi.fn>;
|
||||
upsertUnmatchedBooks: ReturnType<typeof vi.fn>;
|
||||
clearUnmatchedBooks: ReturnType<typeof vi.fn>;
|
||||
getAllDeviceProgress: ReturnType<typeof vi.fn>;
|
||||
getReadingProgress: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@@ -36,6 +38,7 @@ describe('KoreaderPluginService', () => {
|
||||
listSweeps: ReturnType<typeof vi.fn>;
|
||||
getPluginTotals: ReturnType<typeof vi.fn>;
|
||||
getLibraryMaxFileTimestamp: ReturnType<typeof vi.fn>;
|
||||
getHashLinkVersion: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let koreaderService: { applyProgressForResolvedFile: ReturnType<typeof vi.fn> };
|
||||
let userBookStatusService: { findOne: ReturnType<typeof vi.fn>; setManual: ReturnType<typeof vi.fn> };
|
||||
@@ -50,6 +53,8 @@ describe('KoreaderPluginService', () => {
|
||||
koreaderRepo = {
|
||||
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1]),
|
||||
resolveBookFilesByHashes: vi.fn().mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]])),
|
||||
upsertUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
|
||||
clearUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
|
||||
getAllDeviceProgress: vi.fn().mockResolvedValue([]),
|
||||
getReadingProgress: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
@@ -58,8 +63,9 @@ describe('KoreaderPluginService', () => {
|
||||
upsertRating: vi.fn().mockResolvedValue(undefined),
|
||||
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 }),
|
||||
getPluginTotals: vi.fn().mockResolvedValue({ matchedBooks: 0, pageStatEvents: 0, annotations: 0, unmatchedBooks: 0 }),
|
||||
getLibraryMaxFileTimestamp: vi.fn().mockResolvedValue(new Date('2026-06-01T00:00:00.000Z')),
|
||||
getHashLinkVersion: vi.fn().mockResolvedValue({ count: 0, maxTs: null }),
|
||||
};
|
||||
koreaderService = { applyProgressForResolvedFile: vi.fn().mockResolvedValue(undefined) };
|
||||
userBookStatusService = { findOne: vi.fn().mockResolvedValue(null), setManual: vi.fn().mockResolvedValue(undefined) };
|
||||
@@ -80,11 +86,64 @@ describe('KoreaderPluginService', () => {
|
||||
|
||||
const result = await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A, HASH_B], [1]);
|
||||
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A, HASH_B], [1], 7);
|
||||
expect(koreaderRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
|
||||
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [{ hash: HASH_B, source: 'statistics' }]);
|
||||
expect(result.matches).toEqual([{ hash: HASH_A, bookId: 20, bookFileId: 10 }]);
|
||||
expect(result.libraryVersion).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('persists unmatched candidate metadata from the device statistics database', async () => {
|
||||
const dto = {
|
||||
...deviceFields(),
|
||||
hashes: [HASH_A, HASH_B],
|
||||
books: [
|
||||
{ hash: HASH_A, title: 'Matched title', authors: 'Matched author', lastOpen: 100 },
|
||||
{ hash: HASH_B.toUpperCase(), title: 'Unmatched title', authors: 'Author One', lastOpen: 200 },
|
||||
],
|
||||
} as MatchCheckDto;
|
||||
|
||||
await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
|
||||
{ hash: HASH_B, title: 'Unmatched title', authors: 'Author One', lastOpen: 200, source: 'statistics', metadataAmbiguous: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the strongest unmatched source and ambiguity flag for duplicate candidate metadata', async () => {
|
||||
const dto = {
|
||||
...deviceFields(),
|
||||
hashes: [HASH_B],
|
||||
books: [
|
||||
{ hash: HASH_B, title: 'Stats title', lastOpen: 100, source: 'statistics' },
|
||||
{ hash: HASH_B, title: 'File title', authors: 'File author', lastOpen: 200, source: 'file', metadataAmbiguous: true },
|
||||
],
|
||||
} as MatchCheckDto;
|
||||
|
||||
await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
|
||||
{ hash: HASH_B, title: 'File title', authors: 'File author', lastOpen: 200, source: 'file', metadataAmbiguous: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not overwrite stronger unmatched metadata with a weaker later candidate', async () => {
|
||||
const dto = {
|
||||
...deviceFields(),
|
||||
hashes: [HASH_B],
|
||||
books: [
|
||||
{ hash: HASH_B, title: 'Open file title', authors: 'Open file author', lastOpen: 200, source: 'current_file', metadataAmbiguous: false },
|
||||
{ hash: HASH_B, title: 'Stats title', authors: 'Stats author', lastOpen: 300, source: 'statistics', metadataAmbiguous: true },
|
||||
],
|
||||
} as MatchCheckDto;
|
||||
|
||||
await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
|
||||
{ hash: HASH_B, title: 'Open file title', authors: 'Open file author', lastOpen: 300, source: 'current_file', metadataAmbiguous: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('changes the library version token when the accessible library set changes', async () => {
|
||||
const dto = { ...deviceFields(), hashes: [HASH_A] } as MatchCheckDto;
|
||||
|
||||
@@ -94,6 +153,28 @@ describe('KoreaderPluginService', () => {
|
||||
|
||||
expect(second.libraryVersion).not.toBe(first.libraryVersion);
|
||||
});
|
||||
|
||||
it('changes the library version token when manual hash links change', async () => {
|
||||
const dto = { ...deviceFields(), hashes: [HASH_A] } as MatchCheckDto;
|
||||
|
||||
const first = await service.matchCheck(makeUser(), dto);
|
||||
pluginRepo.getHashLinkVersion.mockResolvedValue({ count: 1, maxTs: new Date('2026-06-02T00:00:00.000Z') });
|
||||
const second = await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(second.libraryVersion).not.toBe(first.libraryVersion);
|
||||
});
|
||||
|
||||
it('changes the library version token when a manual hash link count changes without a newer timestamp', async () => {
|
||||
const dto = { ...deviceFields(), hashes: [HASH_A] } as MatchCheckDto;
|
||||
const maxTs = new Date('2026-06-02T00:00:00.000Z');
|
||||
pluginRepo.getHashLinkVersion.mockResolvedValue({ count: 2, maxTs });
|
||||
|
||||
const first = await service.matchCheck(makeUser(), dto);
|
||||
pluginRepo.getHashLinkVersion.mockResolvedValue({ count: 1, maxTs });
|
||||
const second = await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(second.libraryVersion).not.toBe(first.libraryVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadBookStates', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const MATCH_EVENT = 'koreader.plugin.match_check';
|
||||
const BOOK_STATES_EVENT = 'koreader.plugin.book_states';
|
||||
const BULK_PROGRESS_EVENT = 'koreader.plugin.bulk_progress';
|
||||
const SWEEP_EVENT = 'koreader.plugin.sweep';
|
||||
const UNMATCHED_SOURCE_RANK = { statistics: 0, file: 1, current_file: 2 } as const;
|
||||
|
||||
const DEVICE_STATUS_TO_READ_STATUS: Record<string, ReadStatus> = {
|
||||
reading: 'reading',
|
||||
@@ -63,14 +64,20 @@ export class KoreaderPluginService {
|
||||
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const hashes = [...new Set(dto.hashes.map((hash) => hash.toLowerCase()))];
|
||||
const resolved = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
|
||||
const resolved = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
const matchedHashes = [...resolved.keys()];
|
||||
const unmatchedCandidates = this.buildUnmatchedCandidates(hashes, matchedHashes, dto);
|
||||
await Promise.all([
|
||||
this.koreaderRepo.clearUnmatchedBooks(user.id, matchedHashes),
|
||||
this.koreaderRepo.upsertUnmatchedBooks(user.id, unmatchedCandidates),
|
||||
]);
|
||||
|
||||
const matches = [...resolved.entries()].map(([hash, match]) => ({
|
||||
hash,
|
||||
bookId: match.bookId,
|
||||
bookFileId: match.bookFileId,
|
||||
}));
|
||||
const libraryVersion = await this.computeLibraryVersion(accessibleLibraryIds);
|
||||
const libraryVersion = await this.computeLibraryVersion(user.id, accessibleLibraryIds);
|
||||
|
||||
this.logger.log(
|
||||
`[${MATCH_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} matched=${matches.length} total=${hashes.length} - match check completed`,
|
||||
@@ -88,7 +95,7 @@ export class KoreaderPluginService {
|
||||
try {
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
|
||||
const results: BookStatesUploadResult['results'] = [];
|
||||
const unmatched: string[] = [];
|
||||
@@ -135,7 +142,7 @@ export class KoreaderPluginService {
|
||||
try {
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const hashes = [...new Set(dto.items.map((item) => item.hash.toLowerCase()))];
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
|
||||
const results: BulkProgressResult['results'] = [];
|
||||
const unmatched: string[] = [];
|
||||
@@ -194,7 +201,7 @@ export class KoreaderPluginService {
|
||||
});
|
||||
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const libraryVersion = await this.computeLibraryVersion(accessibleLibraryIds);
|
||||
const libraryVersion = await this.computeLibraryVersion(user.id, accessibleLibraryIds);
|
||||
|
||||
this.logger.log(
|
||||
`[${SWEEP_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} booksMatched=${dto.booksMatched} pageStats=${dto.pageStatsUploaded} annotations=${dto.annotationsUpserted} - sweep recorded`,
|
||||
@@ -203,15 +210,53 @@ export class KoreaderPluginService {
|
||||
return { ok: true, lastSweepAt: lastSweepAt.toISOString(), libraryVersion };
|
||||
}
|
||||
|
||||
private async computeLibraryVersion(accessibleLibraryIds: number[] | null): Promise<string> {
|
||||
const maxTs = await this.pluginRepo.getLibraryMaxFileTimestamp(accessibleLibraryIds);
|
||||
private async computeLibraryVersion(userId: number, accessibleLibraryIds: number[] | null): Promise<string> {
|
||||
const [fileMaxTs, linkVersion] = await Promise.all([
|
||||
this.pluginRepo.getLibraryMaxFileTimestamp(accessibleLibraryIds),
|
||||
this.pluginRepo.getHashLinkVersion(userId),
|
||||
]);
|
||||
const maxTs = maxDate(fileMaxTs, linkVersion.maxTs);
|
||||
const libraryKey = accessibleLibraryIds === null ? 'all' : [...accessibleLibraryIds].sort((a, b) => a - b).join(',');
|
||||
const linkKey = `${linkVersion.count}:${linkVersion.maxTs ? linkVersion.maxTs.toISOString() : 'none'}`;
|
||||
return createHash('md5') // codeql[js/weak-cryptographic-algorithm] - non-security cache token
|
||||
.update(`${libraryKey}|${maxTs ? maxTs.toISOString() : 'none'}`)
|
||||
.update(`${libraryKey}|${maxTs ? maxTs.toISOString() : 'none'}|${linkKey}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
private buildUnmatchedCandidates(hashes: string[], matchedHashes: string[], dto: MatchCheckDto) {
|
||||
const matched = new Set(matchedHashes);
|
||||
const metadata = new Map<
|
||||
string,
|
||||
{
|
||||
hash: string;
|
||||
title?: string | null;
|
||||
authors?: string | null;
|
||||
lastOpen?: number | null;
|
||||
source?: 'current_file' | 'file' | 'statistics';
|
||||
metadataAmbiguous?: boolean;
|
||||
}
|
||||
>();
|
||||
for (const book of dto.books ?? []) {
|
||||
const hash = book.hash.toLowerCase();
|
||||
const existing = metadata.get(hash);
|
||||
const source = strongerUnmatchedSource(existing?.source, book.source);
|
||||
const incomingIsStronger = UNMATCHED_SOURCE_RANK[source] > UNMATCHED_SOURCE_RANK[existing?.source ?? 'statistics'];
|
||||
const incomingKeepsSource = source === (book.source ?? 'statistics');
|
||||
metadata.set(hash, {
|
||||
hash,
|
||||
title: incomingIsStronger || incomingKeepsSource ? (book.title ?? existing?.title ?? null) : (existing?.title ?? book.title ?? null),
|
||||
authors:
|
||||
incomingIsStronger || incomingKeepsSource ? (book.authors ?? existing?.authors ?? null) : (existing?.authors ?? book.authors ?? null),
|
||||
lastOpen: Math.max(book.lastOpen ?? 0, existing?.lastOpen ?? 0) || null,
|
||||
source,
|
||||
metadataAmbiguous: incomingIsStronger || incomingKeepsSource ? (book.metadataAmbiguous ?? false) : (existing?.metadataAmbiguous ?? false),
|
||||
});
|
||||
}
|
||||
|
||||
return hashes.filter((hash) => !matched.has(hash)).map((hash) => metadata.get(hash) ?? { hash, source: 'statistics' as const });
|
||||
}
|
||||
|
||||
private async applyStatus(userId: number, bookId: number, deviceStatus: string, statusModified?: string): Promise<boolean> {
|
||||
const mapped = DEVICE_STATUS_TO_READ_STATUS[deviceStatus];
|
||||
if (!mapped) return false;
|
||||
@@ -261,3 +306,21 @@ export class KoreaderPluginService {
|
||||
return timestamp < newestKnown;
|
||||
}
|
||||
}
|
||||
|
||||
function maxDate(...dates: (Date | null)[]): Date | null {
|
||||
let newest: Date | null = null;
|
||||
for (const date of dates) {
|
||||
if (!date) continue;
|
||||
if (!newest || date > newest) newest = date;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
function strongerUnmatchedSource(
|
||||
existing: 'current_file' | 'file' | 'statistics' | undefined,
|
||||
incoming: 'current_file' | 'file' | 'statistics' | undefined,
|
||||
): 'current_file' | 'file' | 'statistics' {
|
||||
const current = existing ?? 'statistics';
|
||||
const next = incoming ?? 'statistics';
|
||||
return UNMATCHED_SOURCE_RANK[next] > UNMATCHED_SOURCE_RANK[current] ? next : current;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('KoreaderStatsService', () => {
|
||||
|
||||
const result = await service.uploadPageStats(makeUser(), dto);
|
||||
|
||||
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A], [1]);
|
||||
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A], [1], 7);
|
||||
expect(result.results[0]!.hash).toBe(HASH_A);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export class KoreaderStatsService {
|
||||
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
|
||||
const timeZone = resolveTimeZone((user.settings as unknown as UserSettings | undefined)?.timezone, 'UTC');
|
||||
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
|
||||
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
|
||||
|
||||
const results: PageStatsBookResult[] = [];
|
||||
const unmatched: string[] = [];
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
|
||||
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
|
||||
import { KoreaderAuthGuard } from './koreader-auth.guard';
|
||||
import { KoreaderController } from './koreader.controller';
|
||||
|
||||
function makeController() {
|
||||
const koreaderService = {
|
||||
saveProgress: vi.fn().mockResolvedValue({ document: 'abc', timestamp: 1 }),
|
||||
getProgress: vi.fn().mockResolvedValue({ document: 'abc', percentage: 0.5 }),
|
||||
createCredentials: vi.fn().mockResolvedValue(undefined),
|
||||
updateCredentials: vi.fn().mockResolvedValue(undefined),
|
||||
deleteCredentials: vi.fn().mockResolvedValue(undefined),
|
||||
getCredentials: vi.fn().mockResolvedValue({ username: 'reader', syncEnabled: true, createdAt: '2026-01-01' }),
|
||||
getSyncStatus: vi.fn().mockResolvedValue({ credentials: null }),
|
||||
getDevices: vi.fn().mockResolvedValue([]),
|
||||
getBookProgress: vi.fn().mockResolvedValue(null),
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const hashLinkService = {
|
||||
listUnmatchedBooks: vi.fn().mockResolvedValue([]),
|
||||
linkUnmatchedBook: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32), bookId: 1, bookFileId: 2 }),
|
||||
listManualHashLinks: vi.fn().mockResolvedValue([]),
|
||||
relinkManualHashLink: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32), bookId: 1, bookFileId: 2 }),
|
||||
unlinkManualHashLink: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32) }),
|
||||
};
|
||||
const packageService = {
|
||||
buildPluginPackage: vi.fn().mockResolvedValue(Buffer.from('fake-zip-content')),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new KoreaderController(koreaderService as never, hashLinkService as never, packageService as never),
|
||||
koreaderService,
|
||||
hashLinkService,
|
||||
packageService,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KoreaderController', () => {
|
||||
it('uses the KOReader auth guard and marks itself public only on kosync protocol routes', () => {
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.authenticateKoreader)).toBe(true);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, KoreaderController.prototype.authenticateKoreader)).toEqual([KoreaderAuthGuard]);
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.saveProgress)).toBe(true);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, KoreaderController.prototype.saveProgress)).toEqual([KoreaderAuthGuard]);
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.getProgress)).toBe(true);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, KoreaderController.prototype.getProgress)).toEqual([KoreaderAuthGuard]);
|
||||
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.getSyncStatus)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.getSyncStatus)).toBe(Permission.KoreaderSync);
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.listUnmatchedBooks)).toBe(Permission.KoreaderSync);
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.unlinkManualHashLink)).toBe(Permission.KoreaderSync);
|
||||
});
|
||||
|
||||
it('registerKoreader always rejects plugin self-registration', () => {
|
||||
const { controller } = makeController();
|
||||
|
||||
expect(() => controller.registerKoreader()).toThrow('Registration disabled. Create credentials in BookOrbit settings.');
|
||||
});
|
||||
|
||||
it('forwards kosync protocol routes to the service', async () => {
|
||||
const { controller, koreaderService } = makeController();
|
||||
const user = { id: 7 } as never;
|
||||
|
||||
expect(controller.authenticateKoreader('reader')).toEqual({ authorized: 'OK', username: 'reader' });
|
||||
|
||||
const progressDto = { document: 'abc', percentage: 0.5 } as never;
|
||||
await controller.saveProgress(user, progressDto);
|
||||
expect(koreaderService.saveProgress).toHaveBeenCalledWith(7, progressDto);
|
||||
|
||||
await expect(controller.getProgress(user, 'abc')).resolves.toEqual({ document: 'abc', percentage: 0.5 });
|
||||
expect(koreaderService.getProgress).toHaveBeenCalledWith(7, 'abc');
|
||||
});
|
||||
|
||||
it('returns an empty object when no progress is found for a document', async () => {
|
||||
const { controller, koreaderService } = makeController();
|
||||
koreaderService.getProgress.mockResolvedValue(null);
|
||||
|
||||
await expect(controller.getProgress({ id: 7 } as never, 'abc')).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('forwards credential management routes to the service', async () => {
|
||||
const { controller, koreaderService } = makeController();
|
||||
const user = { id: 7 } as never;
|
||||
|
||||
await expect(controller.createCredentials(user, { username: 'reader', password: 'secret1' } as never)).resolves.toEqual({ success: true });
|
||||
expect(koreaderService.createCredentials).toHaveBeenCalledWith(7, 'reader', 'secret1');
|
||||
|
||||
await expect(controller.updateCredentials(user, { syncEnabled: false } as never)).resolves.toEqual({ success: true });
|
||||
expect(koreaderService.updateCredentials).toHaveBeenCalledWith(7, { syncEnabled: false });
|
||||
|
||||
await expect(controller.deleteCredentials(user)).resolves.toEqual({ success: true });
|
||||
expect(koreaderService.deleteCredentials).toHaveBeenCalledWith(7);
|
||||
|
||||
await expect(controller.getCredentials(user)).resolves.toEqual({ username: 'reader', syncEnabled: true, createdAt: '2026-01-01' });
|
||||
expect(koreaderService.getCredentials).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('forwards sync status, devices, and book progress routes to the service', async () => {
|
||||
const { controller, koreaderService } = makeController();
|
||||
const user = { id: 7 } as never;
|
||||
|
||||
await expect(controller.getSyncStatus(user)).resolves.toEqual({ credentials: null });
|
||||
expect(koreaderService.getSyncStatus).toHaveBeenCalledWith(7);
|
||||
|
||||
await expect(controller.getDevices(user)).resolves.toEqual([]);
|
||||
expect(koreaderService.getDevices).toHaveBeenCalledWith(7);
|
||||
|
||||
await expect(controller.getBookProgress(user, 10)).resolves.toBeNull();
|
||||
expect(koreaderService.getBookProgress).toHaveBeenCalledWith(7, 10);
|
||||
});
|
||||
|
||||
it('forwards unmatched-book and manual hash link routes to the hash link service', async () => {
|
||||
const { controller, hashLinkService } = makeController();
|
||||
const user = { id: 7 } as never;
|
||||
const hash = 'a'.repeat(32);
|
||||
|
||||
await expect(controller.listUnmatchedBooks(user)).resolves.toEqual([]);
|
||||
expect(hashLinkService.listUnmatchedBooks).toHaveBeenCalledWith(user);
|
||||
|
||||
await expect(controller.linkUnmatchedBook(user, hash, { bookId: 55 } as never)).resolves.toEqual({ hash, bookId: 1, bookFileId: 2 });
|
||||
expect(hashLinkService.linkUnmatchedBook).toHaveBeenCalledWith(user, hash, 55);
|
||||
|
||||
await expect(controller.listManualHashLinks(user)).resolves.toEqual([]);
|
||||
expect(hashLinkService.listManualHashLinks).toHaveBeenCalledWith(user);
|
||||
|
||||
await expect(controller.relinkManualHashLink(user, hash, { bookId: 66 } as never)).resolves.toEqual({ hash, bookId: 1, bookFileId: 2 });
|
||||
expect(hashLinkService.relinkManualHashLink).toHaveBeenCalledWith(user, hash, 66);
|
||||
|
||||
await expect(controller.unlinkManualHashLink(user, hash)).resolves.toEqual({ hash });
|
||||
expect(hashLinkService.unlinkManualHashLink).toHaveBeenCalledWith(user, hash);
|
||||
});
|
||||
|
||||
it('downloads the preconfigured plugin package with the expected headers', async () => {
|
||||
const { controller, packageService } = makeController();
|
||||
const user = { id: 7 } as never;
|
||||
const reply = { header: vi.fn().mockReturnThis(), send: vi.fn() } as never;
|
||||
|
||||
await controller.downloadPluginPackage(user, { origin: 'https://bookorbit.example' } as never, reply);
|
||||
|
||||
expect(packageService.buildPluginPackage).toHaveBeenCalledWith(7, 'https://bookorbit.example');
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'application/zip');
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="bookorbit.koplugin.zip"');
|
||||
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
||||
expect(reply.send).toHaveBeenCalledWith(Buffer.from('fake-zip-content'));
|
||||
});
|
||||
|
||||
it('forwards test-connection requests and reports the plugin server URL', async () => {
|
||||
const { controller, koreaderService } = makeController();
|
||||
const user = { id: 7 } as never;
|
||||
|
||||
await expect(controller.testConnection(user, { username: 'reader', password: 'secret1' } as never)).resolves.toEqual({
|
||||
success: true,
|
||||
username: 'reader',
|
||||
serverUrl: '/api/v1/koreader',
|
||||
});
|
||||
expect(koreaderService.testConnection).toHaveBeenCalledWith(7, 'reader', 'secret1');
|
||||
});
|
||||
});
|
||||
@@ -22,14 +22,24 @@ import { Public } from '../../common/decorators/public.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { KoreaderAuthGuard } from './koreader-auth.guard';
|
||||
import { KoreaderHashLinkService } from './koreader-hash-link.service';
|
||||
import { KoreaderPackageService } from './koreader-package.service';
|
||||
import { KoreaderService } from './koreader.service';
|
||||
import { CreateKoreaderUserDto, DownloadPluginPackageDto, KoreaderSaveProgressDto, TestConnectionDto, UpdateKoreaderUserDto } from './dto';
|
||||
import {
|
||||
CreateKoreaderUserDto,
|
||||
DownloadPluginPackageDto,
|
||||
KoreaderSaveProgressDto,
|
||||
LinkKoreaderUnmatchedBookDto,
|
||||
TestConnectionDto,
|
||||
UpdateKoreaderManualHashLinkDto,
|
||||
UpdateKoreaderUserDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('koreader')
|
||||
export class KoreaderController {
|
||||
constructor(
|
||||
private readonly koreaderService: KoreaderService,
|
||||
private readonly hashLinkService: KoreaderHashLinkService,
|
||||
private readonly packageService: KoreaderPackageService,
|
||||
) {}
|
||||
|
||||
@@ -104,6 +114,36 @@ export class KoreaderController {
|
||||
return this.koreaderService.getDevices(user.id);
|
||||
}
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
@Get('unmatched-books')
|
||||
listUnmatchedBooks(@CurrentUser() user: RequestUser) {
|
||||
return this.hashLinkService.listUnmatchedBooks(user);
|
||||
}
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
@Post('unmatched-books/:hash/link')
|
||||
linkUnmatchedBook(@CurrentUser() user: RequestUser, @Param('hash') hash: string, @Body() dto: LinkKoreaderUnmatchedBookDto) {
|
||||
return this.hashLinkService.linkUnmatchedBook(user, hash, dto.bookId);
|
||||
}
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
@Get('hash-links')
|
||||
listManualHashLinks(@CurrentUser() user: RequestUser) {
|
||||
return this.hashLinkService.listManualHashLinks(user);
|
||||
}
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
@Patch('hash-links/:hash')
|
||||
relinkManualHashLink(@CurrentUser() user: RequestUser, @Param('hash') hash: string, @Body() dto: UpdateKoreaderManualHashLinkDto) {
|
||||
return this.hashLinkService.relinkManualHashLink(user, hash, dto.bookId);
|
||||
}
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
@Delete('hash-links/:hash')
|
||||
unlinkManualHashLink(@CurrentUser() user: RequestUser, @Param('hash') hash: string) {
|
||||
return this.hashLinkService.unlinkManualHashLink(user, hash);
|
||||
}
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
@Get('books/:bookId/progress')
|
||||
getBookProgress(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { KoreaderAnnotationExchangeService } from './koreader-annotation-exchange.service';
|
||||
import { KoreaderAuthGuard } from './koreader-auth.guard';
|
||||
import { KoreaderCatalogController } from './koreader-catalog.controller';
|
||||
import { KoreaderCatalogService } from './koreader-catalog.service';
|
||||
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
|
||||
import { KoreaderChapterService } from './koreader-chapter.service';
|
||||
import { KoreaderController } from './koreader.controller';
|
||||
import { KoreaderHashLinkService } from './koreader-hash-link.service';
|
||||
import { KoreaderModule } from './koreader.module';
|
||||
import { KoreaderPackageService } from './koreader-package.service';
|
||||
import { KoreaderPluginAnnotationService } from './koreader-plugin-annotation.service';
|
||||
import { KoreaderPluginController } from './koreader-plugin.controller';
|
||||
import { KoreaderPluginRepository } from './koreader-plugin.repository';
|
||||
import { KoreaderPluginService } from './koreader-plugin.service';
|
||||
import { KoreaderRepository } from './koreader.repository';
|
||||
import { KoreaderService } from './koreader.service';
|
||||
import { KoreaderStatsService } from './koreader-stats.service';
|
||||
|
||||
describe('KoreaderModule', () => {
|
||||
it('registers expected controllers, providers, and exports', () => {
|
||||
expect(Reflect.getMetadata('controllers', KoreaderModule)).toEqual([KoreaderController, KoreaderPluginController, KoreaderCatalogController]);
|
||||
expect(Reflect.getMetadata('providers', KoreaderModule)).toEqual([
|
||||
KoreaderService,
|
||||
KoreaderHashLinkService,
|
||||
KoreaderRepository,
|
||||
KoreaderAuthGuard,
|
||||
KoreaderCatalogService,
|
||||
KoreaderPackageService,
|
||||
KoreaderChapterService,
|
||||
KoreaderChapterExtractorService,
|
||||
KoreaderPluginService,
|
||||
KoreaderPluginRepository,
|
||||
KoreaderPluginAnnotationService,
|
||||
KoreaderAnnotationExchangeService,
|
||||
KoreaderStatsService,
|
||||
]);
|
||||
expect(Reflect.getMetadata('exports', KoreaderModule)).toEqual([KoreaderService, KoreaderRepository]);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { KoreaderPackageService } from './koreader-package.service';
|
||||
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
|
||||
import { KoreaderChapterService } from './koreader-chapter.service';
|
||||
import { KoreaderController } from './koreader.controller';
|
||||
import { KoreaderHashLinkService } from './koreader-hash-link.service';
|
||||
import { KoreaderPluginAnnotationService } from './koreader-plugin-annotation.service';
|
||||
import { KoreaderPluginController } from './koreader-plugin.controller';
|
||||
import { KoreaderPluginRepository } from './koreader-plugin.repository';
|
||||
@@ -40,6 +41,7 @@ import { KoreaderStatsService } from './koreader-stats.service';
|
||||
controllers: [KoreaderController, KoreaderPluginController, KoreaderCatalogController],
|
||||
providers: [
|
||||
KoreaderService,
|
||||
KoreaderHashLinkService,
|
||||
KoreaderRepository,
|
||||
KoreaderAuthGuard,
|
||||
KoreaderCatalogService,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sqlChunkText } from '../../common/test-utils/sql-chunk-text';
|
||||
import { KoreaderRepository } from './koreader.repository';
|
||||
|
||||
function makeQueryChain(result: unknown) {
|
||||
@@ -13,6 +14,7 @@ function makeQueryChain(result: unknown) {
|
||||
chain.where = vi.fn().mockReturnValue(chain);
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain);
|
||||
chain.limit = vi.fn().mockResolvedValue(result);
|
||||
chain.returning = vi.fn().mockResolvedValue(result);
|
||||
return chain;
|
||||
}
|
||||
|
||||
@@ -74,6 +76,28 @@ describe('KoreaderRepository', () => {
|
||||
expect(result).toEqual(file);
|
||||
expect(db.select).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to a user-scoped manual link after direct and history lookups miss', async () => {
|
||||
const file = { id: 10, bookId: 20, libraryId: 1 };
|
||||
db.select
|
||||
.mockReturnValueOnce(makeQueryChain([]))
|
||||
.mockReturnValueOnce(makeQueryChain([]))
|
||||
.mockReturnValueOnce(makeQueryChain([file]));
|
||||
|
||||
const result = await repo.resolveBookFileByHash('manualhash', [1], 7);
|
||||
|
||||
expect(result).toEqual(file);
|
||||
expect(db.select).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns null when a user-scoped manual link lookup also misses', async () => {
|
||||
db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([]));
|
||||
|
||||
const result = await repo.resolveBookFileByHash('manualhash', [1], 7);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(db.select).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBookFilesByHashes', () => {
|
||||
@@ -117,6 +141,188 @@ describe('KoreaderRepository', () => {
|
||||
expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31 });
|
||||
expect(db.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves remaining hashes from user-scoped manual links', async () => {
|
||||
db.select
|
||||
.mockReturnValueOnce(makeQueryChain([{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31 }]))
|
||||
.mockReturnValueOnce(makeQueryChain([]))
|
||||
.mockReturnValueOnce(makeQueryChain([{ hash: 'manual', bookFileId: 12, bookId: 22, libraryId: 32 }]));
|
||||
|
||||
const result = await repo.resolveBookFilesByHashes(['current', 'manual'], [31, 32], 7);
|
||||
|
||||
expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31 });
|
||||
expect(result.get('manual')).toEqual({ bookFileId: 12, bookId: 22, libraryId: 32 });
|
||||
expect(db.select).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmatched books and manual hash links', () => {
|
||||
it('upserts unmatched candidates with trimmed nullable metadata', async () => {
|
||||
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
db.insert.mockReturnValue({ values });
|
||||
|
||||
await repo.upsertUnmatchedBooks(7, [
|
||||
{ hash: 'a'.repeat(32), title: ' Title ', authors: ' Author ', lastOpen: 100, source: 'file', metadataAmbiguous: true },
|
||||
]);
|
||||
|
||||
expect(values).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
hash: 'a'.repeat(32),
|
||||
title: 'Title',
|
||||
authors: 'Author',
|
||||
lastOpen: 100,
|
||||
source: 'file',
|
||||
metadataAmbiguous: true,
|
||||
lastSeenAt: expect.any(Date),
|
||||
}),
|
||||
]);
|
||||
expect(onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: expect.any(Array),
|
||||
set: expect.objectContaining({
|
||||
source: expect.anything(),
|
||||
metadataAmbiguous: expect.anything(),
|
||||
lastSeenAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const conflictSet = onConflictDoUpdate.mock.calls[0]![0].set as Record<string, unknown>;
|
||||
expect(sqlChunkText(conflictSet.source)).toContain("case excluded.source when 'current_file' then 2 when 'file' then 1 else 0 end");
|
||||
expect(sqlChunkText(conflictSet.source)).toContain('>=');
|
||||
expect(sqlChunkText(conflictSet.source)).toContain('excluded.source');
|
||||
expect(sqlChunkText(conflictSet.metadataAmbiguous)).toContain('excluded.metadata_ambiguous');
|
||||
expect(sqlChunkText(conflictSet.title)).toContain('coalesce');
|
||||
});
|
||||
|
||||
it('clears unmatched books for a user and hash set', async () => {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
db.delete.mockReturnValue({ where });
|
||||
|
||||
await repo.clearUnmatchedBooks(7, ['a'.repeat(32), 'b'.repeat(32)]);
|
||||
|
||||
expect(db.delete).toHaveBeenCalledTimes(1);
|
||||
expect(where).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not issue a delete query when clearing an empty hash set', async () => {
|
||||
await repo.clearUnmatchedBooks(7, []);
|
||||
|
||||
expect(db.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists unmatched books newest first with the requested limit', async () => {
|
||||
const rows = [{ hash: 'a'.repeat(32), lastSeenAt: new Date() }];
|
||||
const chain = makeQueryChain(rows);
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
await expect(repo.listUnmatchedBooks(7, 25)).resolves.toBe(rows);
|
||||
expect(chain.orderBy).toHaveBeenCalledTimes(1);
|
||||
expect(chain.limit).toHaveBeenCalledWith(25);
|
||||
});
|
||||
|
||||
it('returns one unmatched book for a user and hash', async () => {
|
||||
const row = { hash: 'a'.repeat(32), title: 'Stats title' };
|
||||
db.select.mockReturnValue(makeQueryChain([row]));
|
||||
|
||||
await expect(repo.getUnmatchedBook(7, 'a'.repeat(32))).resolves.toBe(row);
|
||||
});
|
||||
|
||||
it('returns null when no unmatched book exists for the user and hash', async () => {
|
||||
db.select.mockReturnValue(makeQueryChain([]));
|
||||
|
||||
await expect(repo.getUnmatchedBook(7, 'a'.repeat(32))).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('lists manual hash links with aggregated BookOrbit authors', async () => {
|
||||
const linkRows = [
|
||||
{
|
||||
hash: 'a'.repeat(32),
|
||||
bookFileId: 44,
|
||||
bookId: 55,
|
||||
bookTitle: 'BookOrbit Title',
|
||||
koreaderTitle: 'KOReader Title',
|
||||
koreaderAuthors: 'KOReader Author',
|
||||
koreaderLastOpen: 100,
|
||||
createdAt: new Date('2026-06-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-02T10:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
db.select.mockReturnValueOnce(makeQueryChain(linkRows)).mockReturnValueOnce(makeQueryChain([{ bookId: 55, name: 'BookOrbit Author' }]));
|
||||
|
||||
await expect(repo.listBookHashLinks(7, 25, [1])).resolves.toEqual([{ ...linkRows[0], bookAuthors: ['BookOrbit Author'] }]);
|
||||
expect(db.select).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not query manual hash links when accessible libraries are empty', async () => {
|
||||
await expect(repo.listBookHashLinks(7, 25, [])).resolves.toEqual([]);
|
||||
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts a user-scoped manual hash link with KOReader metadata', async () => {
|
||||
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
db.insert.mockReturnValue({ values });
|
||||
|
||||
await repo.upsertBookHashLink(7, 'a'.repeat(32), 44, { title: ' KOReader Title ', authors: ' Author ', lastOpen: 100 });
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
hash: 'a'.repeat(32),
|
||||
bookFileId: 44,
|
||||
koreaderTitle: 'KOReader Title',
|
||||
koreaderAuthors: 'Author',
|
||||
koreaderLastOpen: 100,
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: expect.any(Array),
|
||||
set: expect.objectContaining({ bookFileId: 44, updatedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('upserts a manual hash link with null metadata when none is provided', async () => {
|
||||
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
db.insert.mockReturnValue({ values });
|
||||
|
||||
await repo.upsertBookHashLink(7, 'a'.repeat(32), 44);
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
koreaderTitle: null,
|
||||
koreaderAuthors: null,
|
||||
koreaderLastOpen: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an existing manual hash link for the user', async () => {
|
||||
db.select.mockReturnValue(makeQueryChain([{ bookFileId: 44 }]));
|
||||
|
||||
await expect(repo.getBookHashLink(7, 'a'.repeat(32))).resolves.toEqual({ bookFileId: 44 });
|
||||
});
|
||||
|
||||
it('returns null when no manual hash link exists for the user', async () => {
|
||||
db.select.mockReturnValue(makeQueryChain([]));
|
||||
|
||||
await expect(repo.getBookHashLink(7, 'a'.repeat(32))).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('deletes and returns a user-scoped manual hash link', async () => {
|
||||
const row = { hash: 'a'.repeat(32), bookFileId: 44, koreaderTitle: 'Title', koreaderAuthors: 'Author', koreaderLastOpen: 100 };
|
||||
const chain = makeQueryChain([row]);
|
||||
db.delete.mockReturnValue(chain);
|
||||
|
||||
await expect(repo.deleteBookHashLink(7, 'a'.repeat(32))).resolves.toEqual(row);
|
||||
expect(chain.returning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessibleLibraryIds', () => {
|
||||
|
||||
@@ -7,6 +7,20 @@ import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type ResolvedBookFileByHash = { id: number; bookId: number; libraryId: number };
|
||||
type KoreaderUnmatchedSource = 'current_file' | 'file' | 'statistics';
|
||||
type KoreaderUnmatchedCandidate = {
|
||||
hash: string;
|
||||
title?: string | null;
|
||||
authors?: string | null;
|
||||
lastOpen?: number | null;
|
||||
source?: KoreaderUnmatchedSource;
|
||||
metadataAmbiguous?: boolean;
|
||||
};
|
||||
type KoreaderHashLinkMetadata = {
|
||||
title?: string | null;
|
||||
authors?: string | null;
|
||||
lastOpen?: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class KoreaderRepository {
|
||||
@@ -37,7 +51,7 @@ export class KoreaderRepository {
|
||||
await this.db.delete(schema.koreaderUsers).where(eq(schema.koreaderUsers.userId, userId));
|
||||
}
|
||||
|
||||
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null): Promise<ResolvedBookFileByHash | null> {
|
||||
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null, userId?: number): Promise<ResolvedBookFileByHash | null> {
|
||||
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null;
|
||||
|
||||
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
|
||||
@@ -61,12 +75,25 @@ export class KoreaderRepository {
|
||||
|
||||
if (byFileHashHistory) return byFileHashHistory;
|
||||
|
||||
if (userId !== undefined) {
|
||||
const [byManualLink] = await this.db
|
||||
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId })
|
||||
.from(schema.koreaderBookHashLinks)
|
||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
|
||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash), libraryFilter))
|
||||
.limit(1);
|
||||
|
||||
if (byManualLink) return byManualLink;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async resolveBookFilesByHashes(
|
||||
hashes: string[],
|
||||
accessibleLibraryIds: number[] | null,
|
||||
userId?: number,
|
||||
): Promise<Map<string, { bookFileId: number; bookId: number; libraryId: number }>> {
|
||||
const result = new Map<string, { bookFileId: number; bookId: number; libraryId: number }>();
|
||||
if (hashes.length === 0) return result;
|
||||
@@ -112,9 +139,219 @@ export class KoreaderRepository {
|
||||
}
|
||||
}
|
||||
|
||||
const stillMissing = missing.filter((hash) => !result.has(hash));
|
||||
if (stillMissing.length === 0 || userId === undefined) return result;
|
||||
|
||||
const manualLinks = await this.db
|
||||
.select({
|
||||
hash: schema.koreaderBookHashLinks.hash,
|
||||
bookFileId: schema.bookFiles.id,
|
||||
bookId: schema.bookFiles.bookId,
|
||||
libraryId: schema.books.libraryId,
|
||||
})
|
||||
.from(schema.koreaderBookHashLinks)
|
||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
|
||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), inArray(schema.koreaderBookHashLinks.hash, stillMissing), libraryFilter));
|
||||
|
||||
for (const row of manualLinks) {
|
||||
if (!result.has(row.hash)) {
|
||||
result.set(row.hash, { bookFileId: row.bookFileId, bookId: row.bookId, libraryId: row.libraryId });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async upsertUnmatchedBooks(userId: number, candidates: KoreaderUnmatchedCandidate[]): Promise<void> {
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const now = new Date();
|
||||
const incomingSourceRank = sql<number>`case excluded.source when 'current_file' then 2 when 'file' then 1 else 0 end`;
|
||||
const storedSourceRank = sql<number>`case ${schema.koreaderUnmatchedBooks.source} when 'current_file' then 2 when 'file' then 1 else 0 end`;
|
||||
await this.db
|
||||
.insert(schema.koreaderUnmatchedBooks)
|
||||
.values(
|
||||
candidates.map((candidate) => ({
|
||||
userId,
|
||||
hash: candidate.hash,
|
||||
title: candidate.title?.trim() || null,
|
||||
authors: candidate.authors?.trim() || null,
|
||||
lastOpen: candidate.lastOpen ?? null,
|
||||
source: candidate.source ?? 'statistics',
|
||||
metadataAmbiguous: candidate.metadataAmbiguous ?? false,
|
||||
lastSeenAt: now,
|
||||
})),
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.koreaderUnmatchedBooks.userId, schema.koreaderUnmatchedBooks.hash],
|
||||
set: {
|
||||
title: sql`
|
||||
case
|
||||
when ${incomingSourceRank} >= ${storedSourceRank} then coalesce(excluded.title, ${schema.koreaderUnmatchedBooks.title})
|
||||
else coalesce(${schema.koreaderUnmatchedBooks.title}, excluded.title)
|
||||
end
|
||||
`,
|
||||
authors: sql`
|
||||
case
|
||||
when ${incomingSourceRank} >= ${storedSourceRank} then coalesce(excluded.authors, ${schema.koreaderUnmatchedBooks.authors})
|
||||
else coalesce(${schema.koreaderUnmatchedBooks.authors}, excluded.authors)
|
||||
end
|
||||
`,
|
||||
lastOpen: sql`
|
||||
case
|
||||
when excluded.last_open is null then ${schema.koreaderUnmatchedBooks.lastOpen}
|
||||
when ${schema.koreaderUnmatchedBooks.lastOpen} is null then excluded.last_open
|
||||
else greatest(excluded.last_open, ${schema.koreaderUnmatchedBooks.lastOpen})
|
||||
end
|
||||
`,
|
||||
source: sql`
|
||||
case
|
||||
when ${incomingSourceRank} >= ${storedSourceRank} then excluded.source
|
||||
else ${schema.koreaderUnmatchedBooks.source}
|
||||
end
|
||||
`,
|
||||
metadataAmbiguous: sql`
|
||||
case
|
||||
when ${incomingSourceRank} >= ${storedSourceRank} then excluded.metadata_ambiguous
|
||||
else ${schema.koreaderUnmatchedBooks.metadataAmbiguous}
|
||||
end
|
||||
`,
|
||||
lastSeenAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async clearUnmatchedBooks(userId: number, hashes: string[]): Promise<void> {
|
||||
if (hashes.length === 0) return;
|
||||
await this.db
|
||||
.delete(schema.koreaderUnmatchedBooks)
|
||||
.where(and(eq(schema.koreaderUnmatchedBooks.userId, userId), inArray(schema.koreaderUnmatchedBooks.hash, hashes)));
|
||||
}
|
||||
|
||||
async listUnmatchedBooks(userId: number, limit: number) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(schema.koreaderUnmatchedBooks)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.koreaderUnmatchedBooks.userId, userId),
|
||||
inArray(schema.koreaderUnmatchedBooks.source, ['current_file', 'file']),
|
||||
eq(schema.koreaderUnmatchedBooks.metadataAmbiguous, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.koreaderUnmatchedBooks.lastSeenAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async getUnmatchedBook(userId: number, hash: string) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(schema.koreaderUnmatchedBooks)
|
||||
.where(and(eq(schema.koreaderUnmatchedBooks.userId, userId), eq(schema.koreaderUnmatchedBooks.hash, hash)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async listBookHashLinks(userId: number, limit: number, accessibleLibraryIds: number[] | null) {
|
||||
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return [];
|
||||
|
||||
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
|
||||
const rows = await this.db
|
||||
.select({
|
||||
hash: schema.koreaderBookHashLinks.hash,
|
||||
bookFileId: schema.bookFiles.id,
|
||||
bookId: schema.bookFiles.bookId,
|
||||
bookTitle: schema.bookMetadata.title,
|
||||
koreaderTitle: schema.koreaderBookHashLinks.koreaderTitle,
|
||||
koreaderAuthors: schema.koreaderBookHashLinks.koreaderAuthors,
|
||||
koreaderLastOpen: schema.koreaderBookHashLinks.koreaderLastOpen,
|
||||
createdAt: schema.koreaderBookHashLinks.createdAt,
|
||||
updatedAt: schema.koreaderBookHashLinks.updatedAt,
|
||||
})
|
||||
.from(schema.koreaderBookHashLinks)
|
||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
|
||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), libraryFilter))
|
||||
.orderBy(desc(schema.koreaderBookHashLinks.updatedAt))
|
||||
.limit(limit);
|
||||
|
||||
const bookIds = [...new Set(rows.map((row) => row.bookId))];
|
||||
const authorRows =
|
||||
bookIds.length > 0
|
||||
? await this.db
|
||||
.select({ bookId: schema.bookAuthors.bookId, name: schema.authors.name })
|
||||
.from(schema.bookAuthors)
|
||||
.innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
|
||||
.where(inArray(schema.bookAuthors.bookId, bookIds))
|
||||
.orderBy(schema.bookAuthors.displayOrder)
|
||||
: [];
|
||||
|
||||
const authorsByBook = new Map<number, string[]>();
|
||||
for (const row of authorRows) {
|
||||
const list = authorsByBook.get(row.bookId) ?? [];
|
||||
list.push(row.name);
|
||||
authorsByBook.set(row.bookId, list);
|
||||
}
|
||||
|
||||
return rows.map((row) => ({ ...row, bookAuthors: authorsByBook.get(row.bookId) ?? [] }));
|
||||
}
|
||||
|
||||
async upsertBookHashLink(userId: number, hash: string, bookFileId: number, metadata: KoreaderHashLinkMetadata = {}) {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.insert(schema.koreaderBookHashLinks)
|
||||
.values({
|
||||
userId,
|
||||
hash,
|
||||
bookFileId,
|
||||
koreaderTitle: metadata.title?.trim() || null,
|
||||
koreaderAuthors: metadata.authors?.trim() || null,
|
||||
koreaderLastOpen: metadata.lastOpen ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.koreaderBookHashLinks.userId, schema.koreaderBookHashLinks.hash],
|
||||
set: {
|
||||
bookFileId,
|
||||
koreaderTitle: sql`coalesce(excluded.koreader_title, ${schema.koreaderBookHashLinks.koreaderTitle})`,
|
||||
koreaderAuthors: sql`coalesce(excluded.koreader_authors, ${schema.koreaderBookHashLinks.koreaderAuthors})`,
|
||||
koreaderLastOpen: sql`
|
||||
case
|
||||
when excluded.koreader_last_open is null then ${schema.koreaderBookHashLinks.koreaderLastOpen}
|
||||
when ${schema.koreaderBookHashLinks.koreaderLastOpen} is null then excluded.koreader_last_open
|
||||
else greatest(excluded.koreader_last_open, ${schema.koreaderBookHashLinks.koreaderLastOpen})
|
||||
end
|
||||
`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBookHashLink(userId: number, hash: string): Promise<{ bookFileId: number } | null> {
|
||||
const [row] = await this.db
|
||||
.select({ bookFileId: schema.koreaderBookHashLinks.bookFileId })
|
||||
.from(schema.koreaderBookHashLinks)
|
||||
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async deleteBookHashLink(userId: number, hash: string) {
|
||||
const [row] = await this.db
|
||||
.delete(schema.koreaderBookHashLinks)
|
||||
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash)))
|
||||
.returning({
|
||||
hash: schema.koreaderBookHashLinks.hash,
|
||||
bookFileId: schema.koreaderBookHashLinks.bookFileId,
|
||||
koreaderTitle: schema.koreaderBookHashLinks.koreaderTitle,
|
||||
koreaderAuthors: schema.koreaderBookHashLinks.koreaderAuthors,
|
||||
koreaderLastOpen: schema.koreaderBookHashLinks.koreaderLastOpen,
|
||||
});
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async getAccessibleLibraryIds(userId: number): Promise<number[] | null> {
|
||||
const user = await this.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
|
||||
@@ -162,6 +162,7 @@ describe('KoreaderService', () => {
|
||||
failedPositions: 0,
|
||||
pageStatEvents: 0,
|
||||
annotations: 0,
|
||||
unmatchedBooks: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -348,7 +349,7 @@ describe('KoreaderService', () => {
|
||||
timestamp: 1700000000,
|
||||
});
|
||||
|
||||
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('abcdef1234567890fedcba', [1, 2]);
|
||||
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('abcdef1234567890fedcba', [1, 2], 12);
|
||||
expect(mockChapterService.parseChapterIndexFromProgress).toHaveBeenCalledWith('/body/DocFragment[7]');
|
||||
expect(mockChapterExtractor.extractAndStoreChapters).toHaveBeenCalledWith(44);
|
||||
expect(mockRepo.upsertDeviceProgress).toHaveBeenCalledWith({
|
||||
@@ -407,7 +408,7 @@ describe('KoreaderService', () => {
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('no-access-document', []);
|
||||
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('no-access-document', [], 12);
|
||||
});
|
||||
|
||||
it('uses the default device and generated device id when the payload leaves them empty', async () => {
|
||||
@@ -587,7 +588,15 @@ describe('KoreaderService', () => {
|
||||
latestPluginVersion: null,
|
||||
pluginUpdateAvailable: false,
|
||||
sweeps: [],
|
||||
pluginTotals: { matchedBooks: 0, trashedAnnotations: 0, pendingDeletes: 0, failedPositions: 0, pageStatEvents: 0, annotations: 0 },
|
||||
pluginTotals: {
|
||||
matchedBooks: 0,
|
||||
trashedAnnotations: 0,
|
||||
pendingDeletes: 0,
|
||||
failedPositions: 0,
|
||||
pageStatEvents: 0,
|
||||
annotations: 0,
|
||||
unmatchedBooks: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getCredentialsSpy).toHaveBeenCalledWith(7);
|
||||
|
||||
@@ -116,7 +116,7 @@ export class KoreaderService {
|
||||
this.logger.debug(`[${SYNC_EVENT}] [start] userId=${userId} document=${data.document.slice(0, 16)} device=${device} - save progress started`);
|
||||
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
|
||||
const bookFile = await this.repo.resolveBookFileByHash(data.document, accessibleLibraryIds);
|
||||
const bookFile = await this.repo.resolveBookFileByHash(data.document, accessibleLibraryIds, userId);
|
||||
|
||||
if (!bookFile) {
|
||||
this.logger.debug(
|
||||
@@ -188,7 +188,7 @@ export class KoreaderService {
|
||||
|
||||
async getProgress(userId: number, documentHash: string) {
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
|
||||
const bookFile = await this.repo.resolveBookFileByHash(documentHash, accessibleLibraryIds);
|
||||
const bookFile = await this.repo.resolveBookFileByHash(documentHash, accessibleLibraryIds, userId);
|
||||
|
||||
if (!bookFile) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user