feat(server): import Booklore reading state

Import Booklore and Grimmory reading sessions, personal ratings, and progress state through the migration pipeline.

Preserve book-level progress as primary-file progress when source file progress rows are absent.

Closes #482

Closes #483
This commit is contained in:
neon
2026-06-30 18:52:16 +00:00
parent 2dad3eea0b
commit 14b262fa35
19 changed files with 1113 additions and 39 deletions
@@ -489,6 +489,7 @@ const reportData = computed(() => {
userBookStatus: m('user_state', 'user_book_status'),
readingProgress: m('user_state', 'reading_progress'),
audiobookProgress: m('user_state', 'audiobook_progress'),
readingSessions: m('user_state', 'reading_sessions'),
bookmarks: m('user_state', 'bookmarks'),
annotations: m('user_state', 'annotations'),
collections: m('user_state', 'collections'),
@@ -1176,11 +1177,12 @@ function friendlyMatchStrategy(strategy: string | null): string {
function describeUserPreviewCounts(counts: {
statuses: number
fileProgress: number
readingSessions: number
bookmarks: number
annotations: number
shelves: number
}): string {
return `${counts.statuses} statuses, ${counts.fileProgress} progress entries, ${counts.bookmarks} bookmarks, ${counts.annotations} annotations, ${counts.shelves} collection entries`
return `${counts.statuses} statuses, ${counts.fileProgress} progress entries, ${counts.readingSessions ?? 0} reading sessions, ${counts.bookmarks} bookmarks, ${counts.annotations} annotations, ${counts.shelves} collection entries`
}
function extractPlanUnresolvedBooks(planPayload: unknown): ReportUnresolvedBook[] {
@@ -1880,6 +1882,10 @@ const sourceTypeCompatibility = computed<SourceTypeCompatibility | null>(() => S
<span class="text-muted-foreground">Audiobook progress entries</span
><span class="font-medium">{{ reportData.audiobookProgress?.imported ?? 0 }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Reading sessions</span
><span class="font-medium">{{ reportData.readingSessions?.imported ?? 0 }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Bookmarks</span
><span class="font-medium">{{ reportData.bookmarks?.imported ?? 0 }}</span>
@@ -77,6 +77,7 @@ export interface MigrationRunReport {
counts: {
statuses: number
fileProgress: number
readingSessions: number
bookmarks: number
annotations: number
shelves: number
@@ -187,6 +187,7 @@ describe('BookloreSourceAdapter validation and export', () => {
BOOKLORE_TABLES.bookMetadataAuthorMapping,
BOOKLORE_TABLES.userBookProgress,
BOOKLORE_TABLES.userBookFileProgress,
BOOKLORE_TABLES.readingSessions,
BOOKLORE_TABLES.bookMarks,
BOOKLORE_TABLES.annotations,
BOOKLORE_TABLES.shelf,
@@ -241,6 +242,18 @@ describe('BookloreSourceAdapter validation and export', () => {
'position_seconds',
'updated_at',
),
[BOOKLORE_TABLES.readingSessions]: columns(
'id',
'user_id',
'book_id',
'book_type',
'start_time',
'end_time',
'duration_seconds',
'progress_delta',
'end_progress',
'created_at',
),
[BOOKLORE_TABLES.bookMarks]: columns('user_id', 'book_id', 'book_file_id', 'title', 'cfi', 'position_seconds', 'track_index', 'created_at'),
[BOOKLORE_TABLES.annotations]: columns(
'user_id',
@@ -331,6 +344,22 @@ describe('BookloreSourceAdapter validation and export', () => {
},
]);
}
if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.readingSessions}\` s`)) {
return Promise.resolve([
{
sourceSessionId: 'rs-1',
sourceUserId: 'u1',
sourceBookId: 'b1',
bookType: 'EPUB',
startedAt: '2024-01-05T10:00:00.000Z',
endedAt: '2024-01-05T10:30:00.000Z',
durationSeconds: 1500,
progressDelta: 12.5,
endProgress: 75,
createdAt: '2024-01-05T10:30:01.000Z',
},
]);
}
if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.bookMarks}\` b`)) {
return Promise.resolve([
{
@@ -397,6 +426,7 @@ describe('BookloreSourceAdapter validation and export', () => {
tags: true,
userBookStatuses: true,
readingProgress: true,
readingSessions: true,
bookmarks: true,
annotations: true,
shelves: true,
@@ -415,9 +445,127 @@ describe('BookloreSourceAdapter validation and export', () => {
}),
);
expect(result.books[0]?.authors?.map((author) => author.name)).toEqual(['Frank Herbert', 'Brian Herbert']);
expect(result.readingSessions).toEqual([
{
sourceSessionId: 'rs-1',
sourceUserId: 'u1',
sourceBookId: 'b1',
bookType: 'EPUB',
startedAt: '2024-01-05T10:00:00.000Z',
endedAt: '2024-01-05T10:30:00.000Z',
durationSeconds: 1500,
progressDelta: 12.5,
endProgress: 75,
createdAt: '2024-01-05T10:30:01.000Z',
},
]);
expect(result.shelves).toEqual([{ sourceShelfId: 's1', sourceUserId: 'u1', name: 'Favorites' }]);
expect(result.shelfBooks).toEqual([{ sourceShelfId: 's1', sourceUserId: 'u1', sourceBookId: 'b1' }]);
});
it('uses book-level progress as a fallback when file-level progress is absent', async () => {
const columns = (...names: string[]) => new Set(names.map((name) => name.toLowerCase()));
const connector = {
withConnection: vi.fn().mockImplementation((_config, fn) => fn({})),
listTables: vi
.fn()
.mockResolvedValue(
new Set([BOOKLORE_TABLES.users, BOOKLORE_TABLES.book, BOOKLORE_TABLES.userBookProgress, BOOKLORE_TABLES.userBookFileProgress]),
),
listColumns: vi.fn().mockImplementation((_conn, tableName: string) => {
const map: Record<string, Set<string>> = {
[BOOKLORE_TABLES.users]: columns('id'),
[BOOKLORE_TABLES.book]: columns('id'),
[BOOKLORE_TABLES.userBookProgress]: columns(
'user_id',
'book_id',
'read_status',
'epub_progress_percent',
'epub_progress',
'epub_progress_href',
'last_read_time',
),
[BOOKLORE_TABLES.userBookFileProgress]: columns('user_id', 'book_id', 'progress_percent', 'position_data'),
};
return Promise.resolve(map[tableName] ?? new Set<string>());
}),
queryRows: vi.fn().mockImplementation((_conn, sqlText: string) => {
if (sqlText.includes('FROM `users` u')) return Promise.resolve([]);
if (sqlText.includes('FROM `book` b')) {
return Promise.resolve([{ sourceBookId: 'b1' }, { sourceBookId: 'b2' }]);
}
if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.userBookProgress}\` p`)) {
return Promise.resolve([
{
sourceUserId: 'u1',
sourceBookId: 'b1',
status: 'reading',
percentage: 22,
cfi: 'epubcfi(/6/2)',
href: '/chap1.xhtml',
pageNumber: null,
positionSeconds: null,
updatedAt: '2026-01-11T12:00:00.000Z',
},
{
sourceUserId: 'u1',
sourceBookId: 'b2',
status: 'reading',
percentage: 30,
cfi: null,
href: null,
pageNumber: null,
positionSeconds: null,
updatedAt: '2026-01-12T12:00:00.000Z',
},
]);
}
if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.userBookFileProgress}\` p`)) {
return Promise.resolve([
{
sourceUserId: 'u1',
sourceBookId: 'b2',
sourceFileId: null,
percentage: 55,
cfi: 'epubcfi(/6/4)',
href: null,
pageNumber: null,
positionSeconds: null,
updatedAt: '2026-01-12T13:00:00.000Z',
},
]);
}
return Promise.resolve([]);
}),
};
const adapter = new BookloreSourceAdapter(connector as never);
const result = await adapter.exportData({
host: 'localhost',
port: 3306,
user: 'booklore',
password: 'secret',
database: 'booklore',
mediaRootPath: null,
ssl: false,
});
expect(result.availableDomains?.readingProgress).toBe(true);
expect(result.userFileProgress).toEqual([
expect.objectContaining({
sourceBookId: 'b2',
percentage: 55,
cfi: 'epubcfi(/6/4)',
}),
expect.objectContaining({
sourceBookId: 'b1',
sourceFileId: null,
percentage: 22,
cfi: 'epubcfi(/6/2)',
href: '/chap1.xhtml',
}),
]);
});
});
describe('BookloreSourceAdapter snapshot', () => {
@@ -518,7 +666,8 @@ describe('BookloreSourceAdapter validate - missing tables', () => {
'book_metadata table not found; metadata overlays will be limited',
'author mapping tables not found; author migration disabled',
'user_book_progress table not found; status migration disabled',
'user_book_file_progress table not found; file progress migration disabled',
'user_book_file_progress and user_book_progress tables not found; reading progress migration disabled',
'reading_sessions table not found; reading session migration disabled',
'book_marks table not found; bookmark migration disabled',
'annotations table not found; annotation migration disabled',
'shelf mapping tables not found; shelf-to-collection migration disabled',
@@ -999,16 +1148,17 @@ describe('BookloreSourceAdapter private method branches', () => {
expect(connector.queryRows).not.toHaveBeenCalled();
});
it('fetchUserBookStatuses maps Date and string dates via toIso', async () => {
it('fetchUserBookStatuses maps Date, string dates, and personal ratings', async () => {
const isoDate = '2024-06-01T00:00:00.000Z';
const connector = {
listColumns: vi.fn().mockResolvedValue(new Set(['user_id', 'book_id', 'updated_at', 'started_at', 'finished_at'])),
listColumns: vi.fn().mockResolvedValue(new Set(['user_id', 'book_id', 'updated_at', 'started_at', 'finished_at', 'personal_rating'])),
queryRows: vi.fn().mockResolvedValue([
{
sourceUserId: 'u1',
sourceBookId: 'b1',
status: null,
percentage: null,
rating: 8,
startedAt: new Date(isoDate),
finishedAt: '2024-07-01',
updatedAt: '2024-06-15T00:00:00.000Z',
@@ -1021,6 +1171,8 @@ describe('BookloreSourceAdapter private method branches', () => {
expect(result[0].startedAt).toBe(isoDate);
expect(result[0].finishedAt).toBe('2024-07-01T00:00:00.000Z');
expect(result[0].updatedAt).toBeTruthy();
expect(result[0].rating).toBe(8);
expect(connector.queryRows.mock.calls[0][1]).toContain('personal_rating');
});
it('fetchUserBookStatuses filters out rows with empty sourceUserId or sourceBookId', async () => {
@@ -12,6 +12,7 @@ import type {
SourceContributor,
SourceExportDomains,
SourceExportData,
SourceReadingSession,
SourceShelf,
SourceShelfBook,
SourceSnapshot,
@@ -34,6 +35,7 @@ const TABLE_CANDIDATES = {
authorMapping: [BOOKLORE_TABLES.bookMetadataAuthorMapping],
userBookProgress: [BOOKLORE_TABLES.userBookProgress],
userBookFileProgress: [BOOKLORE_TABLES.userBookFileProgress],
readingSessions: [BOOKLORE_TABLES.readingSessions],
bookmarks: [BOOKLORE_TABLES.bookMarks],
annotations: [BOOKLORE_TABLES.annotations],
pdfAnnotations: [BOOKLORE_TABLES.pdfAnnotations],
@@ -65,6 +67,7 @@ interface TableResolution {
authorMapping: string | null;
userBookProgress: string | null;
userBookFileProgress: string | null;
readingSessions: string | null;
bookmarks: string | null;
annotations: string | null;
pdfAnnotations: string | null;
@@ -140,7 +143,10 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
const users = await this.fetchUsers(conn, resolved.users);
const books = await this.fetchBooks(conn, resolved);
const userBookStatuses = await this.fetchUserBookStatuses(conn, resolved.userBookProgress);
const userFileProgress = await this.fetchUserFileProgress(conn, resolved.userBookFileProgress, resolved.bookFile);
const fileProgress = await this.fetchUserFileProgress(conn, resolved.userBookFileProgress, resolved.bookFile);
const bookLevelProgress = await this.fetchBookLevelFileProgress(conn, resolved.userBookProgress);
const userFileProgress = mergeProgressFallbacks(fileProgress, bookLevelProgress);
const readingSessions = await this.fetchReadingSessions(conn, resolved.readingSessions);
const bookmarks = await this.fetchBookmarks(conn, resolved.bookmarks);
const annotations = await this.fetchAnnotations(conn, resolved.annotations);
const { shelves, shelfBooks } = await this.fetchShelves(conn, resolved.shelves, resolved.shelfBooks, resolved.book);
@@ -153,6 +159,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
books,
userBookStatuses,
userFileProgress,
readingSessions,
bookmarks,
annotations,
shelves,
@@ -169,7 +176,8 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
genres: !!resolved.categories && !!resolved.categoryMapping,
tags: !!resolved.tags && !!resolved.tagMapping,
userBookStatuses: !!resolved.userBookProgress,
readingProgress: !!resolved.userBookFileProgress,
readingProgress: !!resolved.userBookFileProgress || !!resolved.userBookProgress,
readingSessions: !!resolved.readingSessions,
bookmarks: !!resolved.bookmarks,
annotations: !!resolved.annotations,
shelves: !!resolved.shelves && !!resolved.shelfBooks,
@@ -217,7 +225,12 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
if (!resolved.bookMetadata) warnings.push('book_metadata table not found; metadata overlays will be limited');
if (!resolved.authors || !resolved.authorMapping) warnings.push('author mapping tables not found; author migration disabled');
if (!resolved.userBookProgress) warnings.push('user_book_progress table not found; status migration disabled');
if (!resolved.userBookFileProgress) warnings.push('user_book_file_progress table not found; file progress migration disabled');
if (!resolved.userBookFileProgress && !resolved.userBookProgress) {
warnings.push('user_book_file_progress and user_book_progress tables not found; reading progress migration disabled');
} else if (!resolved.userBookFileProgress) {
warnings.push('user_book_file_progress table not found; falling back to user_book_progress percentages for reading progress');
}
if (!resolved.readingSessions) warnings.push('reading_sessions table not found; reading session migration disabled');
if (!resolved.bookmarks) warnings.push('book_marks table not found; bookmark migration disabled');
if (!resolved.annotations) warnings.push('annotations table not found; annotation migration disabled');
if (!resolved.shelves || !resolved.shelfBooks) warnings.push('shelf mapping tables not found; shelf-to-collection migration disabled');
@@ -652,6 +665,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
const startedAtCol = firstColumn(cols, ['started_at', 'start_date', 'date_started']);
const finishedAtCol = firstColumn(cols, ['finished_at', 'finish_date', 'completed_at', 'date_finished']);
const updatedAtCol = firstColumn(cols, ['updated_at', 'modified_at', 'last_read_at', 'last_read_time']);
const ratingCol = firstColumn(cols, ['personal_rating', 'personalrating', 'user_rating', 'rating']);
const sqlText = `
SELECT
@@ -659,6 +673,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
CAST(p.\`${bookCol}\` AS CHAR) AS sourceBookId,
${sqlString('p', statusCol, 'status')},
${sqlCoalesceNumber('p', pctCols, 'percentage')},
${sqlNumber('p', ratingCol, 'rating')},
${sqlDate('p', startedAtCol, 'startedAt')},
${sqlDate('p', finishedAtCol, 'finishedAt')},
${sqlDate('p', updatedAtCol, 'updatedAt')}
@@ -668,15 +683,21 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
const rows = await this.connector.queryRows(conn, sqlText);
return rows
.map((row) => ({
sourceUserId: asString(row.sourceUserId) ?? '',
sourceBookId: asString(row.sourceBookId) ?? '',
status: asString(row.status),
percentage: asNumber(row.percentage),
startedAt: toIso(row.startedAt),
finishedAt: toIso(row.finishedAt),
updatedAt: toIso(row.updatedAt),
}))
.map((row) => {
const statusRow: SourceUserBookStatus = {
sourceUserId: asString(row.sourceUserId) ?? '',
sourceBookId: asString(row.sourceBookId) ?? '',
status: asString(row.status),
percentage: asNumber(row.percentage),
startedAt: toIso(row.startedAt),
finishedAt: toIso(row.finishedAt),
updatedAt: toIso(row.updatedAt),
};
if (ratingCol) {
statusRow.rating = asNumber(row.rating);
}
return statusRow;
})
.filter((row) => !!row.sourceUserId && !!row.sourceBookId);
}
@@ -747,6 +768,111 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
.filter((row) => !!row.sourceUserId && !!row.sourceBookId);
}
private async fetchBookLevelFileProgress(conn: mysql.Connection, progressTable: string | null): Promise<SourceUserFileProgress[]> {
if (!progressTable) return [];
const cols = await this.connector.listColumns(conn, progressTable);
const userCol = firstColumn(cols, ['user_id', 'userid']);
const bookCol = firstColumn(cols, ['book_id', 'bookid']);
if (!userCol || !bookCol) return [];
const pctCols = columnsInOrder(cols, [
'percentage',
'progress_percent',
'percent_complete',
'epub_progress_percent',
'pdf_progress_percent',
'cbx_progress_percent',
'koreader_progress_percent',
'kobo_progress_percent',
'progress',
]);
const cfiCol = firstColumn(cols, ['epub_progress', 'koreader_progress', 'kobo_location', 'cfi', 'location']);
const hrefCol = firstColumn(cols, ['epub_progress_href', 'position_href', 'href']);
const pageCol = firstColumn(cols, ['page_number', 'page', 'page_index', 'pdf_progress', 'cbx_progress']);
const updatedAtCol = firstColumn(cols, ['updated_at', 'modified_at', 'last_read_at', 'last_read_time']);
const sqlText = `
SELECT
CAST(p.\`${userCol}\` AS CHAR) AS sourceUserId,
CAST(p.\`${bookCol}\` AS CHAR) AS sourceBookId,
NULL AS sourceFileId,
${sqlCoalesceNumber('p', pctCols, 'percentage')},
${sqlString('p', cfiCol, 'cfi')},
${sqlString('p', hrefCol, 'href')},
${sqlNumber('p', pageCol, 'pageNumber')},
NULL AS positionSeconds,
${sqlDate('p', updatedAtCol, 'updatedAt')}
FROM \`${progressTable}\` p
`;
const rows = await this.connector.queryRows(conn, sqlText);
return rows
.map((row) => ({
sourceUserId: asString(row.sourceUserId) ?? '',
sourceBookId: asString(row.sourceBookId) ?? '',
sourceFileId: null,
percentage: asNumber(row.percentage),
cfi: asString(row.cfi),
href: asString(row.href),
pageNumber: asInteger(row.pageNumber),
positionSeconds: null,
updatedAt: toIso(row.updatedAt),
}))
.filter((row) => !!row.sourceUserId && !!row.sourceBookId);
}
private async fetchReadingSessions(conn: mysql.Connection, sessionsTable: string | null): Promise<SourceReadingSession[]> {
if (!sessionsTable) return [];
const cols = await this.connector.listColumns(conn, sessionsTable);
const idCol = firstColumn(cols, ['id', 'session_id']);
const userCol = firstColumn(cols, ['user_id', 'userid']);
const bookCol = firstColumn(cols, ['book_id', 'bookid']);
const startedAtCol = firstColumn(cols, ['start_time', 'started_at', 'start_at', 'started']);
const endedAtCol = firstColumn(cols, ['end_time', 'ended_at', 'end_at', 'ended']);
if (!idCol || !userCol || !bookCol || !startedAtCol || !endedAtCol) return [];
const bookTypeCol = firstColumn(cols, ['book_type', 'booktype', 'file_type', 'format']);
const durationCol = firstColumn(cols, ['duration_seconds', 'duration']);
const progressDeltaCol = firstColumn(cols, ['progress_delta']);
const endProgressCol = firstColumn(cols, ['end_progress']);
const createdAtCol = firstColumn(cols, ['created_at', 'createdat']);
const sqlText = `
SELECT
CAST(s.\`${idCol}\` AS CHAR) AS sourceSessionId,
CAST(s.\`${userCol}\` AS CHAR) AS sourceUserId,
CAST(s.\`${bookCol}\` AS CHAR) AS sourceBookId,
${sqlString('s', bookTypeCol, 'bookType')},
${sqlDate('s', startedAtCol, 'startedAt')},
${sqlDate('s', endedAtCol, 'endedAt')},
${sqlNumber('s', durationCol, 'durationSeconds')},
${sqlNumber('s', progressDeltaCol, 'progressDelta')},
${sqlNumber('s', endProgressCol, 'endProgress')},
${sqlDate('s', createdAtCol, 'createdAt')}
FROM \`${sessionsTable}\` s
`;
const rows = await this.connector.queryRows(conn, sqlText);
return rows
.map((row) => ({
sourceSessionId: asString(row.sourceSessionId) ?? '',
sourceUserId: asString(row.sourceUserId) ?? '',
sourceBookId: asString(row.sourceBookId) ?? '',
bookType: asString(row.bookType),
startedAt: toIso(row.startedAt),
endedAt: toIso(row.endedAt),
durationSeconds: asInteger(row.durationSeconds),
progressDelta: asNumber(row.progressDelta),
endProgress: asNumber(row.endProgress),
createdAt: toIso(row.createdAt),
}))
.filter((row) => !!row.sourceSessionId && !!row.sourceUserId && !!row.sourceBookId);
}
private async fetchBookmarks(conn: mysql.Connection, bookmarkTable: string | null): Promise<SourceBookmark[]> {
if (!bookmarkTable) return [];
@@ -1011,6 +1137,7 @@ function resolveTables(tables: Set<string>): TableResolution {
authorMapping: resolveTable(tables, TABLE_CANDIDATES.authorMapping),
userBookProgress: resolveTable(tables, TABLE_CANDIDATES.userBookProgress),
userBookFileProgress: resolveTable(tables, TABLE_CANDIDATES.userBookFileProgress),
readingSessions: resolveTable(tables, TABLE_CANDIDATES.readingSessions),
bookmarks: resolveTable(tables, TABLE_CANDIDATES.bookmarks),
annotations: resolveTable(tables, TABLE_CANDIDATES.annotations),
pdfAnnotations: resolveTable(tables, TABLE_CANDIDATES.pdfAnnotations),
@@ -1059,6 +1186,21 @@ function columnsInOrder(columns: Set<string>, candidates: string[]): string[] {
return candidates.filter((candidate) => columns.has(candidate.toLowerCase()));
}
function mergeProgressFallbacks(fileProgress: SourceUserFileProgress[], bookLevelProgress: SourceUserFileProgress[]): SourceUserFileProgress[] {
if (bookLevelProgress.length === 0) return fileProgress;
const fileProgressKeys = new Set(fileProgress.filter(hasProgressSignal).map((row) => `${row.sourceUserId}:${row.sourceBookId}`));
const fallbackRows = bookLevelProgress.filter((row) => hasProgressSignal(row) && !fileProgressKeys.has(`${row.sourceUserId}:${row.sourceBookId}`));
return [...fileProgress, ...fallbackRows];
}
function hasProgressSignal(row: SourceUserFileProgress): boolean {
const hasLocator = (row.cfi?.trim().length ?? 0) > 0 || (row.href?.trim().length ?? 0) > 0;
const hasPageNumber = typeof row.pageNumber === 'number' && Number.isFinite(row.pageNumber) && row.pageNumber > 0;
const hasPosition = typeof row.positionSeconds === 'number' && Number.isFinite(row.positionSeconds) && row.positionSeconds > 0;
return (row.percentage ?? 0) > 0 || hasLocator || hasPageNumber || hasPosition;
}
function sqlString(alias: string, column: string | null, outName: string): string {
if (!column) return `NULL AS ${outName}`;
return `CAST(${alias}.\`${column}\` AS CHAR) AS ${outName}`;
@@ -22,6 +22,7 @@ export const BOOKLORE_TABLES = {
newPdfViewerPreference: 'new_pdf_viewer_preference',
pdfAnnotations: 'pdf_annotations',
pdfViewerPreference: 'pdf_viewer_preference',
readingSessions: 'reading_sessions',
shelf: 'shelf',
tag: 'tag',
userBookFileProgress: 'user_book_file_progress',
@@ -82,6 +82,7 @@ export interface SourceUserBookStatus {
sourceBookId: string;
status: string | null;
percentage: number | null;
rating?: number | null;
startedAt: string | null;
finishedAt: string | null;
updatedAt: string | null;
@@ -99,6 +100,19 @@ export interface SourceUserFileProgress {
updatedAt: string | null;
}
export interface SourceReadingSession {
sourceSessionId: string;
sourceUserId: string;
sourceBookId: string;
bookType: string | null;
startedAt: string | null;
endedAt: string | null;
durationSeconds: number | null;
progressDelta: number | null;
endProgress: number | null;
createdAt: string | null;
}
export interface SourceBookmark {
sourceUserId: string;
sourceBookId: string;
@@ -143,6 +157,7 @@ export interface SourceExportDomains {
tags: boolean;
userBookStatuses: boolean;
readingProgress: boolean;
readingSessions: boolean;
bookmarks: boolean;
annotations: boolean;
shelves: boolean;
@@ -154,6 +169,7 @@ export interface SourceExportData {
books: SourceBook[];
userBookStatuses: SourceUserBookStatus[];
userFileProgress: SourceUserFileProgress[];
readingSessions: SourceReadingSession[];
bookmarks: SourceBookmark[];
annotations: SourceAnnotation[];
shelves: SourceShelf[];
@@ -31,7 +31,7 @@ export function uniqueNumbers(values: number[]): number[] {
export function buildSourceFileTargetMap(
planned: PlannerResult,
targetFilesByBookId: Map<number, Array<{ id: number; hash: string | null; absolutePath: string }>>,
targetFilesByBookId: Map<number, Array<{ id: number; hash: string | null; absolutePath: string; format?: string | null }>>,
): Map<string, number> {
const sourceBooksById = new Map(planned.execution.sourceData.books.map((book) => [book.sourceBookId, book]));
const out = new Map<string, number>();
@@ -89,6 +89,7 @@ function createImportRepoMock() {
batchUpsertUserBookStatuses: vi.fn(() => Promise.resolve()),
batchUpsertReadingProgress: vi.fn(() => Promise.resolve()),
batchUpsertAudiobookProgress: vi.fn(() => Promise.resolve()),
syncImportedReadingSessions: vi.fn(() => Promise.resolve()),
batchInsertBookmarks: vi.fn(() => Promise.resolve()),
batchInsertAnnotations: vi.fn(() => Promise.resolve()),
batchInsertCollectionBooks: vi.fn(() => Promise.resolve()),
@@ -477,6 +478,7 @@ describe('UserStateImporter progress and bookmark edge cases', () => {
availableDomains: {
userBookStatuses: false,
readingProgress: false,
readingSessions: false,
bookmarks: false,
annotations: false,
shelves: false,
@@ -497,6 +499,7 @@ describe('UserStateImporter progress and bookmark edge cases', () => {
expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'user_book_status', expect.objectContaining({ processed: 0, skipped: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'reading_progress', expect.objectContaining({ processed: 0, skipped: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'audiobook_progress', expect.objectContaining({ processed: 0, skipped: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'reading_sessions', expect.objectContaining({ processed: 0, skipped: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'bookmarks', expect.objectContaining({ processed: 0, skipped: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'annotations', expect.objectContaining({ processed: 0, skipped: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'collections', expect.objectContaining({ processed: 0, skipped: 0 }));
@@ -63,11 +63,15 @@ describe('MigrationImportRepository', () => {
const seriesIdentity = {
resolveMetadataPatch: vi.fn().mockResolvedValue({ bookId: 1, seriesName: 'Dune', seriesId: 88 }),
};
const seriesMemberships = {
syncPrimaryFromMetadata: vi.fn().mockResolvedValue([]),
};
const repo = new MigrationImportRepository(db as never, seriesIdentity as never);
const repo = new MigrationImportRepository(db as never, seriesIdentity as never, seriesMemberships as never);
await repo.batchUpsertBookMetadata([{ bookId: 1, seriesName: ' Dune ' }]);
expect(seriesIdentity.resolveMetadataPatch).toHaveBeenCalledWith({ bookId: 1, seriesName: ' Dune ' }, db);
expect(seriesMemberships.syncPrimaryFromMetadata).toHaveBeenCalledWith(1, db);
expect(values).toHaveBeenCalledWith({ bookId: 1, seriesName: 'Dune', seriesId: 88 });
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
@@ -93,6 +97,20 @@ describe('MigrationImportRepository', () => {
expect(where).toHaveBeenCalledTimes(1);
});
it('clearUserBookRatings no-ops for empty targets and deduplicates ids otherwise', async () => {
const where = vi.fn().mockResolvedValue(undefined);
const deleteFn = vi.fn().mockReturnValue({ where });
const repo = new MigrationImportRepository({ delete: deleteFn } as never);
await repo.clearUserBookRatings([], [1, 2]);
await repo.clearUserBookRatings([1], []);
expect(deleteFn).not.toHaveBeenCalled();
await repo.clearUserBookRatings([1, 1, 2], [10, 10, 11]);
expect(deleteFn).toHaveBeenCalledTimes(1);
expect(where).toHaveBeenCalledTimes(1);
});
it('batchUpsertAuthors returns name-to-id mapping from inserted rows', async () => {
const returning = vi.fn().mockResolvedValue([
{ id: 10, name: 'Frank Herbert' },
@@ -1,5 +1,5 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { and, eq, gt, inArray, like, lt, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db';
@@ -7,6 +7,13 @@ import { refreshPrimaryAuthorSortNamesForAuthors, refreshPrimaryAuthorSortNamesF
import * as schema from '../../../db/schema';
import { SeriesIdentityService } from '../../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../../common/services/series-membership.service';
import {
aggregateReadingSessionDailyStats,
getDayRangeForDateKeys,
getReadingSessionDayKeys,
type ReadingDailyStatsSegment,
} from '../../../common/utils/reading-daily-stats.utils';
import { resolveTimeZone } from '../../../common/utils/timezone.utils';
import { uniqueNumbers } from './executor-utils';
type Db = NodePgDatabase<typeof schema>;
@@ -209,6 +216,15 @@ export class MigrationImportRepository {
});
}
async clearUserBookRatings(userIds: number[], bookIds: number[]): Promise<void> {
const targetUserIds = uniqueNumbers(userIds);
const targetBookIds = uniqueNumbers(bookIds);
if (targetUserIds.length === 0 || targetBookIds.length === 0) return;
await this.db
.delete(schema.userBookRatings)
.where(and(inArray(schema.userBookRatings.userId, targetUserIds), inArray(schema.userBookRatings.bookId, targetBookIds)));
}
// --- Reading progress ---
async clearReadingProgress(userIds: number[], fileIds: number[]): Promise<void> {
@@ -481,6 +497,22 @@ export class MigrationImportRepository {
}
}
async batchUpsertUserBookRatings(items: Array<typeof schema.userBookRatings.$inferInsert>): Promise<void> {
if (items.length === 0) return;
for (const batch of chunk(items, BATCH_CHUNK_SIZE)) {
await this.db
.insert(schema.userBookRatings)
.values(batch)
.onConflictDoUpdate({
target: [schema.userBookRatings.userId, schema.userBookRatings.bookId],
set: {
rating: sql`excluded.rating`,
updatedAt: sql`excluded.updated_at`,
},
});
}
}
async batchUpsertReadingProgress(items: Array<typeof schema.readingProgress.$inferInsert>): Promise<void> {
if (items.length === 0) return;
for (const batch of chunk(items, BATCH_CHUNK_SIZE)) {
@@ -518,6 +550,115 @@ export class MigrationImportRepository {
}
}
async syncImportedReadingSessions(params: {
items: Array<typeof schema.readingSessions.$inferInsert>;
userIds: number[];
bookIds: number[];
sessionIdPrefix: string;
}): Promise<void> {
const targetUserIds = uniqueNumbers(params.userIds);
const targetBookIds = uniqueNumbers(params.bookIds);
if (targetUserIds.length === 0 || targetBookIds.length === 0) return;
const existing = await this.db
.select({
id: schema.readingSessions.id,
userId: schema.readingSessions.userId,
bookId: schema.readingSessions.bookId,
sessionId: schema.readingSessions.sessionId,
startedAt: schema.readingSessions.startedAt,
endedAt: schema.readingSessions.endedAt,
durationSeconds: schema.readingSessions.durationSeconds,
progressDelta: schema.readingSessions.progressDelta,
libraryId: schema.books.libraryId,
})
.from(schema.readingSessions)
.innerJoin(schema.books, eq(schema.books.id, schema.readingSessions.bookId))
.where(and(inArray(schema.readingSessions.userId, targetUserIds), like(schema.readingSessions.sessionId, `${params.sessionIdPrefix}%`)));
const desiredKeys = new Set(params.items.map((item) => `${item.userId}:${item.sessionId}`));
const targetBookIdSet = new Set(targetBookIds);
const relevantExisting = existing.filter((row) => targetBookIdSet.has(row.bookId) || desiredKeys.has(`${row.userId}:${row.sessionId}`));
const stale = relevantExisting.filter((row) => targetBookIdSet.has(row.bookId) && !desiredKeys.has(`${row.userId}:${row.sessionId}`));
const timeZonesByUserId = await this.fetchTimeZonesByUserIds(targetUserIds);
const affectedDaysByUserLibrary = new Map<string, { userId: number; libraryId: number; timeZone: string; days: Set<string> }>();
const addAffectedDays = (entry: {
userId: number;
libraryId: number;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
progressDelta: number | null;
}) => {
const timeZone = timeZonesByUserId.get(entry.userId) ?? 'UTC';
const key = `${entry.userId}:${entry.libraryId}`;
const group = affectedDaysByUserLibrary.get(key) ?? { userId: entry.userId, libraryId: entry.libraryId, timeZone, days: new Set<string>() };
for (const day of getReadingSessionDayKeys(entry, timeZone)) {
group.days.add(day);
}
affectedDaysByUserLibrary.set(key, group);
};
for (const row of relevantExisting) {
addAffectedDays({
userId: row.userId,
libraryId: row.libraryId,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSeconds: row.durationSeconds,
progressDelta: row.progressDelta ?? null,
});
}
if (stale.length > 0) {
for (const batch of chunk(
stale.map((row) => row.id),
BATCH_CHUNK_SIZE,
)) {
await this.db.delete(schema.readingSessions).where(inArray(schema.readingSessions.id, batch));
}
}
const libraryIdsByBookId = await this.fetchLibraryIdsByBookIds(params.items.map((item) => item.bookId));
for (const item of params.items) {
const libraryId = libraryIdsByBookId.get(item.bookId);
if (!libraryId) continue;
addAffectedDays({
userId: item.userId,
libraryId,
startedAt: item.startedAt,
endedAt: item.endedAt,
durationSeconds: item.durationSeconds,
progressDelta: item.progressDelta ?? null,
});
}
for (const batch of chunk(params.items, BATCH_CHUNK_SIZE)) {
await this.db
.insert(schema.readingSessions)
.values(batch)
.onConflictDoUpdate({
target: [schema.readingSessions.userId, schema.readingSessions.sessionId],
set: {
bookFileId: sql`excluded.book_file_id`,
bookId: sql`excluded.book_id`,
source: sql`excluded.source`,
startedAt: sql`excluded.started_at`,
endedAt: sql`excluded.ended_at`,
durationSeconds: sql`excluded.duration_seconds`,
progressDelta: sql`excluded.progress_delta`,
endProgress: sql`excluded.end_progress`,
},
});
}
for (const group of affectedDaysByUserLibrary.values()) {
await this.recomputeReadingDailyStats(group.userId, group.libraryId, [...group.days], group.timeZone);
}
}
async batchInsertBookmarks(items: Array<typeof schema.bookmarks.$inferInsert>): Promise<void> {
if (items.length === 0) return;
for (const batch of chunk(items, BATCH_CHUNK_SIZE)) {
@@ -591,8 +732,10 @@ export class MigrationImportRepository {
return { primaryFilesByBookId, audiobookPrimaryFilesByBookId };
}
async fetchTargetBookFiles(bookIds: number[]): Promise<Map<number, Array<{ id: number; hash: string | null; absolutePath: string }>>> {
const result = new Map<number, Array<{ id: number; hash: string | null; absolutePath: string }>>();
async fetchTargetBookFiles(
bookIds: number[],
): Promise<Map<number, Array<{ id: number; hash: string | null; absolutePath: string; format: string | null }>>> {
const result = new Map<number, Array<{ id: number; hash: string | null; absolutePath: string; format: string | null }>>();
if (bookIds.length === 0) return result;
const rows = await this.db
@@ -601,13 +744,14 @@ export class MigrationImportRepository {
bookId: schema.bookFiles.bookId,
hash: schema.bookFiles.fileHash,
absolutePath: schema.bookFiles.absolutePath,
format: schema.bookFiles.format,
})
.from(schema.bookFiles)
.where(inArray(schema.bookFiles.bookId, bookIds));
for (const row of rows) {
const files = result.get(row.bookId) ?? [];
files.push({ id: row.id, hash: row.hash, absolutePath: row.absolutePath });
files.push({ id: row.id, hash: row.hash, absolutePath: row.absolutePath, format: row.format });
result.set(row.bookId, files);
}
@@ -629,4 +773,102 @@ export class MigrationImportRepository {
return result;
}
private async fetchTimeZonesByUserIds(userIds: number[]): Promise<Map<number, string>> {
const result = new Map<number, string>();
const targetUserIds = uniqueNumbers(userIds);
if (targetUserIds.length === 0) return result;
const rows = await this.db
.select({ id: schema.users.id, settings: schema.users.settings })
.from(schema.users)
.where(inArray(schema.users.id, targetUserIds));
for (const row of rows) {
result.set(row.id, resolveTimeZone((row.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC'));
}
return result;
}
private async recomputeReadingDailyStats(userId: number, libraryId: number, days: string[], timeZone: string): Promise<void> {
const affectedDays = [...new Set(days)].sort();
if (affectedDays.length === 0) return;
await this.lockReadingDailyStats(userId, libraryId);
await this.db
.delete(schema.userReadingDailyStats)
.where(
and(
eq(schema.userReadingDailyStats.userId, userId),
eq(schema.userReadingDailyStats.libraryId, libraryId),
inArray(schema.userReadingDailyStats.day, affectedDays),
),
);
const range = getDayRangeForDateKeys(affectedDays, timeZone);
if (!range) return;
const rows = await this.db
.select({
startedAt: schema.readingSessions.startedAt,
endedAt: schema.readingSessions.endedAt,
durationSeconds: schema.readingSessions.durationSeconds,
progressDelta: schema.readingSessions.progressDelta,
})
.from(schema.readingSessions)
.innerJoin(schema.books, eq(schema.books.id, schema.readingSessions.bookId))
.where(
and(
eq(schema.readingSessions.userId, userId),
eq(schema.books.libraryId, libraryId),
lt(schema.readingSessions.startedAt, range.end),
gt(schema.readingSessions.endedAt, range.start),
),
);
const segments = aggregateReadingSessionDailyStats(
rows.map((row) => ({
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSeconds: row.durationSeconds,
progressDelta: row.progressDelta ?? null,
})),
timeZone,
new Set(affectedDays),
);
await this.insertReadingDailyStatsSegments(userId, libraryId, segments);
}
private async lockReadingDailyStats(userId: number, libraryId: number): Promise<void> {
await this.db.execute(sql`select pg_advisory_xact_lock(${userId}::int, ${libraryId}::int)`);
}
private async insertReadingDailyStatsSegments(userId: number, libraryId: number, segments: ReadingDailyStatsSegment[]): Promise<void> {
if (segments.length === 0) return;
const now = new Date();
await this.db
.insert(schema.userReadingDailyStats)
.values(
segments.map((segment) => ({
userId,
libraryId,
day: segment.day,
readingSeconds: segment.readingSeconds,
progressDelta: segment.progressDelta,
sessionsCount: segment.sessionsCount,
updatedAt: now,
})),
)
.onConflictDoUpdate({
target: [schema.userReadingDailyStats.userId, schema.userReadingDailyStats.libraryId, schema.userReadingDailyStats.day],
set: {
readingSeconds: sql`excluded.reading_seconds`,
progressDelta: sql`excluded.progress_delta`,
sessionsCount: sql`excluded.sessions_count`,
updatedAt: now,
},
});
}
}
@@ -10,13 +10,16 @@ function makeImporter() {
primaryFilesByBookId: new Map([[200, 500]]),
audiobookPrimaryFilesByBookId: new Map([[200, 500]]),
}),
fetchTargetBookFiles: vi.fn().mockResolvedValue(new Map()),
fetchTargetBookFiles: vi.fn().mockResolvedValue(new Map([[200, [{ id: 500, hash: null, absolutePath: '/target/book.epub', format: 'epub' }]]])),
clearUserBookStatuses: vi.fn().mockResolvedValue(undefined),
batchUpsertUserBookStatuses: vi.fn().mockResolvedValue(undefined),
clearUserBookRatings: vi.fn().mockResolvedValue(undefined),
batchUpsertUserBookRatings: vi.fn().mockResolvedValue(undefined),
clearReadingProgress: vi.fn().mockResolvedValue(undefined),
batchUpsertReadingProgress: vi.fn().mockResolvedValue(undefined),
clearAudiobookProgress: vi.fn().mockResolvedValue(undefined),
batchUpsertAudiobookProgress: vi.fn().mockResolvedValue(undefined),
syncImportedReadingSessions: vi.fn().mockResolvedValue(undefined),
clearBookmarks: vi.fn().mockResolvedValue(undefined),
batchInsertBookmarks: vi.fn().mockResolvedValue(undefined),
clearAnnotations: vi.fn().mockResolvedValue(undefined),
@@ -37,6 +40,7 @@ describe('UserStateImporter', () => {
const planned = {
plan: {
snapshot: { sourceType: 'booklore' },
userMappings: [{ sourceUserId: 'u1', targetUserId: 10 }],
pathMappings: [],
},
@@ -46,6 +50,7 @@ describe('UserStateImporter', () => {
availableDomains: {
userBookStatuses: true,
readingProgress: true,
readingSessions: true,
bookmarks: true,
annotations: true,
shelves: true,
@@ -65,6 +70,7 @@ describe('UserStateImporter', () => {
sourceBookId: 'b-source',
status: 'completed',
percentage: 100,
rating: 8,
startedAt: '2026-01-01T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -82,6 +88,20 @@ describe('UserStateImporter', () => {
updatedAt: '2026-01-03T00:00:00.000Z',
},
],
readingSessions: [
{
sourceSessionId: 'session-1',
sourceUserId: 'u1',
sourceBookId: 'b-source',
bookType: 'EPUB',
startedAt: '2026-01-04T00:00:00.000Z',
endedAt: '2026-01-04T00:30:00.000Z',
durationSeconds: 9999,
progressDelta: 12.345,
endProgress: 120,
createdAt: '2026-01-04T00:30:01.000Z',
},
],
bookmarks: [
{
sourceUserId: 'u1',
@@ -125,6 +145,13 @@ describe('UserStateImporter', () => {
source: 'manual',
}),
]);
expect(importRepo.batchUpsertUserBookRatings).toHaveBeenCalledWith([
expect.objectContaining({
userId: 10,
bookId: 200,
rating: 4,
}),
]);
expect(importRepo.batchUpsertReadingProgress).toHaveBeenCalledWith([
expect.objectContaining({
bookFileId: 500,
@@ -140,6 +167,23 @@ describe('UserStateImporter', () => {
positionSeconds: 30,
}),
]);
expect(importRepo.syncImportedReadingSessions).toHaveBeenCalledWith({
items: [
expect.objectContaining({
userId: 10,
bookId: 200,
bookFileId: 500,
sessionId: 'booklore:rs:session-1',
durationSeconds: 1800,
progressDelta: 12.35,
endProgress: 100,
source: 'web',
}),
],
userIds: [10],
bookIds: [200],
sessionIdPrefix: 'booklore:rs:',
});
expect(importRepo.batchInsertBookmarks).toHaveBeenCalledWith([
expect.objectContaining({
userId: 10,
@@ -165,6 +209,24 @@ describe('UserStateImporter', () => {
imported: 1,
}),
);
expect(repo.setRunMetric).toHaveBeenCalledWith(
300,
'user_state',
'user_book_rating',
expect.objectContaining({
processed: 1,
imported: 1,
}),
);
expect(repo.setRunMetric).toHaveBeenCalledWith(
300,
'user_state',
'reading_sessions',
expect.objectContaining({
processed: 1,
imported: 1,
}),
);
expect(ensureRunning).toHaveBeenCalled();
});
@@ -343,6 +405,7 @@ describe('UserStateImporter', () => {
await importer.import(302, planned as never, vi.fn().mockResolvedValue(undefined));
expect(importRepo.batchUpsertUserBookStatuses).not.toHaveBeenCalled();
expect(importRepo.batchUpsertUserBookRatings).not.toHaveBeenCalled();
expect(importRepo.batchUpsertReadingProgress).not.toHaveBeenCalled();
expect(importRepo.batchUpsertAudiobookProgress).not.toHaveBeenCalled();
expect(importRepo.batchInsertBookmarks).not.toHaveBeenCalled();
@@ -350,6 +413,7 @@ describe('UserStateImporter', () => {
expect(importRepo.batchInsertCollectionBooks).not.toHaveBeenCalled();
expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'user_book_status', expect.objectContaining({ processed: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'user_book_rating', expect.objectContaining({ processed: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'reading_progress', expect.objectContaining({ processed: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'audiobook_progress', expect.objectContaining({ processed: 0 }));
expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'bookmarks', expect.objectContaining({ processed: 0 }));
@@ -543,6 +607,7 @@ describe('UserStateImporter', () => {
sourceBookId: 'b-source',
status: 'reading',
percentage: 20,
rating: 2,
startedAt: null,
finishedAt: null,
updatedAt: '2026-01-10T00:00:00.000Z',
@@ -552,6 +617,7 @@ describe('UserStateImporter', () => {
sourceBookId: 'b-source',
status: 'completed',
percentage: 100,
rating: 9,
startedAt: null,
finishedAt: null,
updatedAt: newerStatusUpdatedAt,
@@ -605,6 +671,20 @@ describe('UserStateImporter', () => {
updatedAt: new Date(newerStatusUpdatedAt),
});
const ratingBatch = importRepo.batchUpsertUserBookRatings.mock.calls[0]?.[0] as Array<{
userId: number;
bookId: number;
rating: number;
updatedAt: Date;
}>;
expect(ratingBatch).toHaveLength(1);
expect(ratingBatch[0]).toMatchObject({
userId: 10,
bookId: 200,
rating: 5,
updatedAt: new Date(newerStatusUpdatedAt),
});
const readingBatch = importRepo.batchUpsertReadingProgress.mock.calls[0]?.[0] as Array<{
userId: number;
bookFileId: number;
@@ -1,7 +1,15 @@
import { Injectable } from '@nestjs/common';
import type { ReadStatus, ReadStatusSource } from '@bookorbit/types';
import { createHash } from 'node:crypto';
import type { SourceBook, SourceBookmark, SourceUserFileProgress } from '../adapters/source-adapter.types';
import { Injectable } from '@nestjs/common';
import { isAudioFormat, isComicFormat, type ReadStatus, type ReadStatusSource } from '@bookorbit/types';
import type {
SourceBook,
SourceBookmark,
SourceReadingSession,
SourceUserBookStatus,
SourceUserFileProgress,
} from '../adapters/source-adapter.types';
import { MigrationRepository } from '../migration.repository';
import { MigrationImportRepository } from './migration-import.repository';
import type { PlannerResult } from '../planner/planner.types';
@@ -29,6 +37,19 @@ function hasMeaningfulProgressSignal(row: SourceUserFileProgress, percentage: nu
return percentage > 0 || hasLocator || hasPageNumber || hasPosition;
}
function hasRatingColumn(row: SourceUserBookStatus): boolean {
return row.rating !== undefined;
}
function normalizeSourceRating(value: number | null | undefined): number | null {
if (value == null || !Number.isFinite(value)) return null;
const rating = Math.round(value);
if (rating < 1) return null;
if (rating <= 5) return rating;
if (rating <= 10) return Math.ceil(rating / 2);
return null;
}
type UserBookStatusUpsert = {
userId: number;
bookId: number;
@@ -39,6 +60,13 @@ type UserBookStatusUpsert = {
updatedAt: Date;
};
type UserBookRatingUpsert = {
userId: number;
bookId: number;
rating: number;
updatedAt: Date;
};
type ReadingProgressUpsert = {
bookFileId: number;
userId: number;
@@ -58,6 +86,19 @@ type AudiobookProgressUpsert = {
updatedAt: Date;
};
type ReadingSessionUpsert = {
userId: number;
bookId: number;
bookFileId: number | null;
sessionId: string;
source: 'web';
startedAt: Date;
endedAt: Date;
durationSeconds: number;
progressDelta: number | null;
endProgress: number | null;
};
@Injectable()
export class UserStateImporter {
constructor(
@@ -75,6 +116,7 @@ export class UserStateImporter {
const sourceFileToTargetFile = buildSourceFileTargetMap(planned, targetFilesByBookId);
await this.importUserBookStatuses(runId, planned, sourceToTargetUser, sourceToTargetBook, ensureRunning);
await this.importUserBookRatings(runId, planned, sourceToTargetUser, sourceToTargetBook, ensureRunning);
await this.importReadingProgress(
runId,
planned,
@@ -93,6 +135,15 @@ export class UserStateImporter {
sourceFileToTargetFile,
ensureRunning,
);
await this.importReadingSessions(
runId,
planned,
sourceToTargetUser,
sourceToTargetBook,
primaryFilesByBookId,
targetFilesByBookId,
ensureRunning,
);
await this.importBookmarks(runId, planned, sourceToTargetUser, sourceToTargetBook, ensureRunning);
await this.importAnnotations(runId, planned, sourceToTargetUser, sourceToTargetBook, ensureRunning);
await this.importCollections(runId, planned, sourceToTargetUser, sourceToTargetBook, ensureRunning);
@@ -143,6 +194,61 @@ export class UserStateImporter {
await this.repo.setRunMetric(runId, 'user_state', 'user_book_status', counters);
}
private async importUserBookRatings(
runId: number,
planned: PlannerResult,
userMap: Map<string, number>,
bookMap: Map<string, number>,
ensureRunning: RunStateCheck,
): Promise<void> {
const counters = emptyCounters();
if (!isDomainAvailable(planned, 'userBookStatuses')) {
await this.repo.setRunMetric(runId, 'user_state', 'user_book_rating', counters);
return;
}
const rows = planned.execution.sourceData.userBookStatuses.filter(hasRatingColumn);
if (rows.length === 0) {
await this.repo.setRunMetric(runId, 'user_state', 'user_book_rating', counters);
return;
}
const batch: UserBookRatingUpsert[] = [];
for (const row of rows) {
await ensureRunning();
counters.processed += 1;
const targetUserId = userMap.get(row.sourceUserId);
const targetBookId = bookMap.get(row.sourceBookId);
if (!targetUserId || !targetBookId) {
counters.unresolved += 1;
continue;
}
const rating = normalizeSourceRating(row.rating);
if (rating == null) {
counters.skipped += 1;
continue;
}
batch.push({
userId: targetUserId,
bookId: targetBookId,
rating,
updatedAt: toDate(row.updatedAt) ?? new Date(),
});
counters.imported += 1;
}
const dedupedBatch = dedupeByKey(batch, (item) => `${item.userId}:${item.bookId}`, preferLatestByUpdatedAt);
await this.importRepo.withTransaction(async (importRepo) => {
await importRepo.clearUserBookRatings([...userMap.values()], [...bookMap.values()]);
await importRepo.batchUpsertUserBookRatings(dedupedBatch);
});
await this.repo.setRunMetric(runId, 'user_state', 'user_book_rating', counters);
}
private async importReadingProgress(
runId: number,
planned: PlannerResult,
@@ -275,6 +381,63 @@ export class UserStateImporter {
await this.repo.setRunMetric(runId, 'user_state', 'audiobook_progress', counters);
}
private async importReadingSessions(
runId: number,
planned: PlannerResult,
userMap: Map<string, number>,
bookMap: Map<string, number>,
primaryFilesByBookId: Map<number, number>,
targetFilesByBookId: Map<number, Array<{ id: number; hash: string | null; absolutePath: string; format: string | null }>>,
ensureRunning: RunStateCheck,
): Promise<void> {
const counters = emptyCounters();
const rows = planned.execution.sourceData.readingSessions;
if (!isDomainAvailable(planned, 'readingSessions') || !Array.isArray(rows)) {
await this.repo.setRunMetric(runId, 'user_state', 'reading_sessions', counters);
return;
}
const sourceType = planned.plan.snapshot?.sourceType ?? 'booklore';
const sessionIdPrefix = `${sourceType}:rs:`;
const batch: ReadingSessionUpsert[] = [];
for (const row of rows) {
await ensureRunning();
counters.processed += 1;
const targetUserId = userMap.get(row.sourceUserId);
const targetBookId = bookMap.get(row.sourceBookId);
if (!targetUserId || !targetBookId) {
counters.unresolved += 1;
continue;
}
const prepared = prepareReadingSessionRow(row, sourceType, targetUserId, targetBookId);
if (!prepared) {
counters.skipped += 1;
continue;
}
batch.push({
...prepared,
bookFileId: resolveReadingSessionBookFileId(row.bookType, targetBookId, targetFilesByBookId, primaryFilesByBookId),
});
counters.imported += 1;
}
const dedupedBatch = dedupeByKey(batch, (item) => `${item.userId}:${item.sessionId}`, preferReadingSessionRow);
await this.importRepo.withTransaction(async (importRepo) => {
await importRepo.syncImportedReadingSessions({
items: dedupedBatch,
userIds: [...userMap.values()],
bookIds: [...bookMap.values()],
sessionIdPrefix,
});
});
await this.repo.setRunMetric(runId, 'user_state', 'reading_sessions', counters);
}
private async importBookmarks(
runId: number,
planned: PlannerResult,
@@ -574,6 +737,96 @@ function readingProgressSignalScore(row: ReadingProgressUpsert): number {
return score;
}
function prepareReadingSessionRow(
row: SourceReadingSession,
sourceType: string,
userId: number,
bookId: number,
): Omit<ReadingSessionUpsert, 'bookFileId'> | null {
const startedAt = toDate(row.startedAt);
const endedAt = toDate(row.endedAt);
if (!startedAt || !endedAt) return null;
if (endedAt.getTime() < startedAt.getTime()) return null;
const wallClockSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
if (wallClockSeconds <= 0) return null;
const sourceDuration =
typeof row.durationSeconds === 'number' && Number.isFinite(row.durationSeconds) ? Math.max(0, Math.trunc(row.durationSeconds)) : wallClockSeconds;
const durationSeconds = Math.min(sourceDuration, wallClockSeconds);
if (durationSeconds < 10) return null;
return {
userId,
bookId,
sessionId: buildImportedReadingSessionId(sourceType, row.sourceSessionId),
source: 'web',
startedAt,
endedAt,
durationSeconds,
progressDelta: clampProgressDelta(row.progressDelta),
endProgress: sanitizeNullablePercent(row.endProgress),
};
}
function buildImportedReadingSessionId(sourceType: string, sourceSessionId: string): string {
const prefix = `${sourceType}:rs:`;
const raw = sourceSessionId.trim();
const candidate = `${prefix}${raw}`;
if (candidate.length <= 64) return candidate;
return `${prefix}${createHash('sha1').update(raw).digest('hex')}`;
}
function sanitizeNullablePercent(value: number | null): number | null {
if (value == null || !Number.isFinite(value)) return null;
return Math.round(clampPercent(value) * 100) / 100;
}
function clampProgressDelta(value: number | null): number | null {
if (value == null || !Number.isFinite(value)) return null;
return Math.round(Math.max(-100, Math.min(100, value)) * 100) / 100;
}
function preferReadingSessionRow(current: ReadingSessionUpsert, candidate: ReadingSessionUpsert): ReadingSessionUpsert {
const currentTs = current.endedAt.getTime();
const candidateTs = candidate.endedAt.getTime();
if (candidateTs > currentTs) return candidate;
if (candidateTs < currentTs) return current;
return candidate;
}
function resolveReadingSessionBookFileId(
bookType: string | null,
bookId: number,
targetFilesByBookId: Map<number, Array<{ id: number; format: string | null }>>,
primaryFilesByBookId: Map<number, number>,
): number | null {
const files = targetFilesByBookId.get(bookId) ?? [];
if (files.length === 0) return null;
const primaryFileId = primaryFilesByBookId.get(bookId) ?? null;
if (!bookType?.trim()) {
if (files.length === 1) return files[0].id;
return primaryFileId;
}
const matches = files.filter((file) => isTargetFileForSourceBookType(file.format, bookType));
if (matches.length === 1) return matches[0].id;
if (primaryFileId && matches.some((file) => file.id === primaryFileId)) return primaryFileId;
return null;
}
function isTargetFileForSourceBookType(format: string | null, bookType: string): boolean {
if (!format) return false;
const normalizedFormat = format.toLowerCase();
const normalizedType = bookType.trim().toUpperCase();
if (normalizedType === 'AUDIOBOOK') return isAudioFormat(normalizedFormat);
if (normalizedType === 'CBX') return isComicFormat(normalizedFormat);
return normalizedFormat === normalizedType.toLowerCase();
}
function resolveBookmarkPositionSeconds(row: SourceBookmark, sourceBook: SourceBook | undefined): number | null {
if (row.positionSeconds == null || !Number.isFinite(row.positionSeconds)) return null;
const positionSeconds = Math.max(0, row.positionSeconds);
@@ -33,6 +33,20 @@ describe('MigrationPlannerService', () => {
userFileProgress: [
{ sourceUserId: 'u1', sourceBookId: 's3', percentage: 10, cfi: null, pageNumber: null, positionSeconds: 5, updatedAt: null },
],
readingSessions: [
{
sourceSessionId: 'rs-1',
sourceUserId: 'u1',
sourceBookId: 's3',
bookType: 'EPUB',
startedAt: '2024-01-01T00:00:00.000Z',
endedAt: '2024-01-01T00:10:00.000Z',
durationSeconds: 600,
progressDelta: 5,
endProgress: 15,
createdAt: null,
},
],
bookmarks: [{ sourceUserId: 'u1', sourceBookId: 's3', title: null, cfi: null, positionSeconds: null, createdAt: null }],
annotations: [
{
@@ -58,6 +72,7 @@ describe('MigrationPlannerService', () => {
tags: true,
userBookStatuses: true,
readingProgress: true,
readingSessions: true,
bookmarks: true,
annotations: true,
shelves: true,
@@ -151,6 +166,7 @@ describe('MigrationPlannerService', () => {
counts: {
statuses: 1,
fileProgress: 1,
readingSessions: 1,
bookmarks: 1,
annotations: 1,
shelves: 1,
@@ -163,6 +179,7 @@ describe('MigrationPlannerService', () => {
counts: {
statuses: 0,
fileProgress: 0,
readingSessions: 0,
bookmarks: 0,
annotations: 0,
shelves: 0,
@@ -182,6 +199,20 @@ describe('MigrationPlannerService', () => {
userFileProgress: [
{ sourceUserId: 'u1', sourceBookId: 'b1', percentage: 100, cfi: null, pageNumber: null, positionSeconds: null, updatedAt: null },
],
readingSessions: [
{
sourceSessionId: 'rs-1',
sourceUserId: 'u1',
sourceBookId: 'b1',
bookType: 'EPUB',
startedAt: '2024-01-01T00:00:00.000Z',
endedAt: '2024-01-01T00:10:00.000Z',
durationSeconds: 600,
progressDelta: 5,
endProgress: 100,
createdAt: null,
},
],
bookmarks: [{ sourceUserId: 'u1', sourceBookId: 'b1', title: null, cfi: null, positionSeconds: null, createdAt: null }],
annotations: [
{
@@ -212,6 +243,7 @@ describe('MigrationPlannerService', () => {
counts: {
statuses: 1,
fileProgress: 1,
readingSessions: 1,
bookmarks: 1,
annotations: 1,
shelves: 1,
@@ -154,10 +154,20 @@ function parsePathMappings(raw: unknown): PathMapping[] {
export function buildUserPreview(sourceData: SourceExportData, mappedUsers: Map<string, number>, matchedBookIds: Set<string>): PlannedUserPreview[] {
const usersById = new Map(sourceData.users.map((user) => [user.sourceUserId, user]));
const countsBySourceUser = new Map<string, { statuses: number; fileProgress: number; bookmarks: number; annotations: number; shelves: number }>();
const countsBySourceUser = new Map<
string,
{ statuses: number; fileProgress: number; readingSessions: number; bookmarks: number; annotations: number; shelves: number }
>();
const increment = (sourceUserId: string, key: 'statuses' | 'fileProgress' | 'bookmarks' | 'annotations' | 'shelves') => {
const current = countsBySourceUser.get(sourceUserId) ?? { statuses: 0, fileProgress: 0, bookmarks: 0, annotations: 0, shelves: 0 };
const increment = (sourceUserId: string, key: 'statuses' | 'fileProgress' | 'readingSessions' | 'bookmarks' | 'annotations' | 'shelves') => {
const current = countsBySourceUser.get(sourceUserId) ?? {
statuses: 0,
fileProgress: 0,
readingSessions: 0,
bookmarks: 0,
annotations: 0,
shelves: 0,
};
current[key] += 1;
countsBySourceUser.set(sourceUserId, current);
};
@@ -168,6 +178,9 @@ export function buildUserPreview(sourceData: SourceExportData, mappedUsers: Map<
for (const row of sourceData.userFileProgress) {
if (mappedUsers.has(row.sourceUserId) && matchedBookIds.has(row.sourceBookId)) increment(row.sourceUserId, 'fileProgress');
}
for (const row of sourceData.readingSessions ?? []) {
if (mappedUsers.has(row.sourceUserId) && matchedBookIds.has(row.sourceBookId)) increment(row.sourceUserId, 'readingSessions');
}
for (const row of sourceData.bookmarks) {
if (mappedUsers.has(row.sourceUserId) && matchedBookIds.has(row.sourceBookId)) increment(row.sourceUserId, 'bookmarks');
}
@@ -187,7 +200,14 @@ export function buildUserPreview(sourceData: SourceExportData, mappedUsers: Map<
return [...mappedUsers.entries()].map(([sourceUserId, targetUserId]) => {
const sourceUser = usersById.get(sourceUserId);
const counts = countsBySourceUser.get(sourceUserId) ?? { statuses: 0, fileProgress: 0, bookmarks: 0, annotations: 0, shelves: 0 };
const counts = countsBySourceUser.get(sourceUserId) ?? {
statuses: 0,
fileProgress: 0,
readingSessions: 0,
bookmarks: 0,
annotations: 0,
shelves: 0,
};
return {
sourceUserId,
targetUserId,
@@ -56,6 +56,7 @@ export interface PlannedUnresolvedBook {
export interface UserPreviewCounts {
statuses: number;
fileProgress: number;
readingSessions: number;
bookmarks: number;
annotations: number;
shelves: number;
@@ -99,7 +99,7 @@ describe('MigrationReportingService', () => {
sourceUserId: 'u1',
targetUserId: 9,
username: 'alice',
counts: { statuses: 1, fileProgress: 2, bookmarks: 3, annotations: 4, shelves: 5 },
counts: { statuses: 1, fileProgress: 2, readingSessions: 6, bookmarks: 3, annotations: 4, shelves: 5 },
},
],
},
@@ -153,7 +153,7 @@ describe('MigrationReportingService', () => {
sourceUserId: 'u1',
targetUserId: 9,
username: 'alice',
counts: { statuses: 1, fileProgress: 2, bookmarks: 3, annotations: 4, shelves: 5 },
counts: { statuses: 1, fileProgress: 2, readingSessions: 6, bookmarks: 3, annotations: 4, shelves: 5 },
},
]);
});
@@ -249,7 +249,7 @@ describe('MigrationReportingService', () => {
sourceUserId: 'u1',
targetUserId: 9,
username: 'alice',
counts: { statuses: 1, fileProgress: 2, bookmarks: 3, annotations: 4, shelves: 5 },
counts: { statuses: 1, fileProgress: 2, readingSessions: 6, bookmarks: 3, annotations: 4, shelves: 5 },
},
],
},
@@ -348,7 +348,7 @@ describe('MigrationReportingService', () => {
sourceUserId: 'u2',
targetUserId: 5,
username: 'bob',
counts: { statuses: 0, fileProgress: 0, bookmarks: 0, annotations: 0, shelves: 0 },
counts: { statuses: 0, fileProgress: 0, readingSessions: 0, bookmarks: 0, annotations: 0, shelves: 0 },
},
],
},
@@ -472,7 +472,7 @@ describe('MigrationReportingService', () => {
sourceUserId: 'u1',
targetUserId: 1,
username: 'user\nwith\nnewlines',
counts: { statuses: 0, fileProgress: 0, bookmarks: 0, annotations: 0, shelves: 0 },
counts: { statuses: 0, fileProgress: 0, readingSessions: 0, bookmarks: 0, annotations: 0, shelves: 0 },
},
],
},
@@ -242,7 +242,7 @@ export class MigrationReportingService {
targetUserId: row.targetUserId,
username: row.username,
reason: null,
details: `statuses=${row.counts.statuses}; fileProgress=${row.counts.fileProgress}; bookmarks=${row.counts.bookmarks}; annotations=${row.counts.annotations}; shelves=${row.counts.shelves}`,
details: `statuses=${row.counts.statuses}; fileProgress=${row.counts.fileProgress}; readingSessions=${row.counts.readingSessions}; bookmarks=${row.counts.bookmarks}; annotations=${row.counts.annotations}; shelves=${row.counts.shelves}`,
code: null,
message: null,
createdAt: report.run.updatedAt?.toISOString() ?? report.run.createdAt.toISOString(),
@@ -374,6 +374,7 @@ function normalizeUserPreview(planValue: unknown, summaryValue: unknown): Planne
counts: {
statuses: typeof counts.statuses === 'number' ? counts.statuses : 0,
fileProgress: typeof counts.fileProgress === 'number' ? counts.fileProgress : 0,
readingSessions: typeof counts.readingSessions === 'number' ? counts.readingSessions : 0,
bookmarks: typeof counts.bookmarks === 'number' ? counts.bookmarks : 0,
annotations: typeof counts.annotations === 'number' ? counts.annotations : 0,
shelves: typeof counts.shelves === 'number' ? counts.shelves : 0,
@@ -546,6 +546,33 @@ export async function seedCanonicalScenario(ctx: MigrationBookloreE2EContext): P
],
);
await insertRows(
conn,
'reading_sessions',
[
'id',
'user_id',
'book_id',
'book_type',
'start_time',
'end_time',
'duration_seconds',
'start_progress',
'end_progress',
'progress_delta',
'start_location',
'end_location',
'duration_formatted',
'created_at',
],
[
[9001, 1, 101, 'EPUB', '2024-01-06 10:00:00', '2024-01-06 10:30:00', 1700, 20, 45, 25, 'start', 'end', '28m', '2024-01-06 10:30:01'],
[9002, 1, 102, 'EPUB', '2024-01-07 10:00:00', '2024-01-07 10:20:00', 5000, 22, 25, 3, null, null, '20m', '2024-01-07 10:20:01'],
[9003, 2, 103, 'AUDIOBOOK', '2024-02-06 09:00:00', '2024-02-06 09:40:00', 1800, null, null, null, null, null, '30m', '2024-02-06 09:40:01'],
[9004, 3, 104, 'EPUB', '2024-03-06 09:00:00', '2024-03-06 09:10:00', 600, 0, 12, 12, null, null, '10m', '2024-03-06 09:10:01'],
],
);
await insertRows(
conn,
'book_marks',
@@ -1010,6 +1037,7 @@ async function createCanonicalSchema(conn: mysql.Connection): Promise<void> {
'CREATE TABLE `book_metadata_author_mapping` (`book_id` INT, `author_id` INT, `sort_order` INT)',
'CREATE TABLE `user_book_progress` (`user_id` INT, `book_id` INT, `status` VARCHAR(50), `percentage` DECIMAL(8,2), `started_at` DATETIME, `finished_at` DATETIME, `updated_at` DATETIME)',
'CREATE TABLE `user_book_file_progress` (`user_id` INT, `book_id` INT, `book_file_id` INT, `percentage` DECIMAL(8,2), `cfi` VARCHAR(2000), `position_href` VARCHAR(255), `page_number` INT, `position_seconds` DECIMAL(10,2), `updated_at` DATETIME)',
'CREATE TABLE `reading_sessions` (`id` INT PRIMARY KEY, `user_id` INT, `book_id` INT, `book_type` VARCHAR(20), `start_time` DATETIME, `end_time` DATETIME, `duration_seconds` INT, `start_progress` DECIMAL(8,2), `end_progress` DECIMAL(8,2), `progress_delta` DECIMAL(8,2), `start_location` VARCHAR(255), `end_location` VARCHAR(255), `duration_formatted` VARCHAR(50), `created_at` DATETIME)',
'CREATE TABLE `book_marks` (`user_id` INT, `book_id` INT, `book_file_id` INT, `title` VARCHAR(255), `cfi` VARCHAR(2000), `position_seconds` DECIMAL(10,2), `track_index` INT, `created_at` DATETIME)',
'CREATE TABLE `annotations` (`user_id` INT, `book_id` INT, `cfi` VARCHAR(2000), `text` TEXT, `color` VARCHAR(20), `style` VARCHAR(20), `note` TEXT, `chapter_title` VARCHAR(255), `created_at` DATETIME, `updated_at` DATETIME)',
'CREATE TABLE `shelf` (`id` INT PRIMARY KEY, `user_id` INT, `name` VARCHAR(255))',
+81 -3
View File
@@ -223,7 +223,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
perUserPreview: Array<{
sourceUserId: string;
targetUserId: number;
counts: { statuses: number; fileProgress: number; bookmarks: number; annotations: number; shelves: number };
counts: { statuses: number; fileProgress: number; readingSessions: number; bookmarks: number; annotations: number; shelves: number };
}>;
};
}>(ctx, {
@@ -254,6 +254,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
counts: expect.objectContaining({
statuses: 1,
fileProgress: 2,
readingSessions: 2,
bookmarks: 1,
annotations: 1,
shelves: 2,
@@ -265,6 +266,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
counts: expect.objectContaining({
statuses: 1,
fileProgress: 1,
readingSessions: 1,
bookmarks: 1,
annotations: 1,
shelves: 1,
@@ -427,11 +429,11 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
expect.arrayContaining([
expect.objectContaining({
username: 'alice-source',
counts: expect.objectContaining({ statuses: 2, fileProgress: 2, bookmarks: 1, annotations: 1, shelves: 2 }),
counts: expect.objectContaining({ statuses: 2, fileProgress: 2, readingSessions: 2, bookmarks: 1, annotations: 1, shelves: 2 }),
}),
expect.objectContaining({
username: 'bob-source',
counts: expect.objectContaining({ statuses: 1, fileProgress: 1, bookmarks: 1, annotations: 1, shelves: 1 }),
counts: expect.objectContaining({ statuses: 1, fileProgress: 1, readingSessions: 1, bookmarks: 1, annotations: 1, shelves: 1 }),
}),
]),
);
@@ -480,6 +482,13 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
processed: 1,
imported: 1,
});
expect(metricByKey.get('user_state:reading_sessions')).toMatchObject({
processed: 4,
imported: 3,
unresolved: 1,
skipped: 0,
failed: 0,
});
expect(metricByKey.get('user_state:bookmarks')).toMatchObject({
processed: 2,
imported: 2,
@@ -622,6 +631,74 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
}),
]);
const readingSessions = await ctx.db
.select()
.from(schema.readingSessions)
.where(inArray(schema.readingSessions.userId, [scenario.targetUsers.alice.id, scenario.targetUsers.bob.id]));
expect(readingSessions).toEqual(
expect.arrayContaining([
expect.objectContaining({
userId: scenario.targetUsers.alice.id,
bookId: scenario.books.isbn.bookId,
bookFileId: scenario.books.isbn.primaryFileId,
sessionId: 'booklore:rs:9001',
source: 'web',
durationSeconds: 1700,
progressDelta: 25,
endProgress: 45,
}),
expect.objectContaining({
userId: scenario.targetUsers.alice.id,
bookId: scenario.books.hash.bookId,
bookFileId: scenario.books.hash.primaryFileId,
sessionId: 'booklore:rs:9002',
durationSeconds: 1200,
progressDelta: 3,
endProgress: 25,
}),
expect.objectContaining({
userId: scenario.targetUsers.bob.id,
bookId: scenario.books.audio.bookId,
bookFileId: scenario.books.audio.primaryFileId,
sessionId: 'booklore:rs:9003',
durationSeconds: 1800,
progressDelta: null,
endProgress: null,
}),
]),
);
expect(readingSessions).toHaveLength(3);
const dailyStats = await ctx.db
.select()
.from(schema.userReadingDailyStats)
.where(inArray(schema.userReadingDailyStats.userId, [scenario.targetUsers.alice.id, scenario.targetUsers.bob.id]));
expect(dailyStats).toEqual(
expect.arrayContaining([
expect.objectContaining({
userId: scenario.targetUsers.alice.id,
day: '2024-01-06',
readingSeconds: 1700,
progressDelta: 25,
sessionsCount: 1,
}),
expect.objectContaining({
userId: scenario.targetUsers.alice.id,
day: '2024-01-07',
readingSeconds: 1200,
progressDelta: 3,
sessionsCount: 1,
}),
expect.objectContaining({
userId: scenario.targetUsers.bob.id,
day: '2024-02-06',
readingSeconds: 1800,
progressDelta: 0,
sessionsCount: 1,
}),
]),
);
const bookmarks = await ctx.db.select().from(schema.bookmarks);
expect(bookmarks).toEqual(
expect.arrayContaining([
@@ -998,6 +1075,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
'book_metadata table not found; metadata overlays will be limited',
'author mapping tables not found; author migration disabled',
'user_book_progress table not found; status migration disabled',
'reading_sessions table not found; reading session migration disabled',
'book_marks table not found; bookmark migration disabled',
'mediaRootPath not configured; book cover/thumbnail import disabled',
]),