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:
@@ -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