mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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))',
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user