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:
neon
2026-07-02 00:32:02 +00:00
parent c48c4a0dad
commit a0dad23087
37 changed files with 15654 additions and 59 deletions
@@ -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
}
]
}
+57
View File
@@ -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
View File
@@ -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;