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
@@ -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',
]),