mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add grouped reading progress routes
Add GET and PUT /reading-progress and DELETE /reading-progress/:fileId: positions grouped by kind, up to 25 files saved in one request, and an optional editedAt so the server keeps the newer of two saves. Move the web reader and book page to them, and list reading-progress in SERVER_FEATURES for the iOS and Android apps, which adopt the routes in the same release.
This commit is contained in:
@@ -21,6 +21,8 @@ import { BookRepository } from './book.repository';
|
||||
import { BookService } from './book.service';
|
||||
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
|
||||
import { ReadingAttemptController } from './reading-attempt.controller';
|
||||
import { ReadingProgressController } from './reading-progress.controller';
|
||||
import { ReadingProgressService } from './reading-progress.service';
|
||||
import { AudiobookEbookProgressSyncService } from './audiobook-ebook-progress-sync.service';
|
||||
import { DeliveryArtifactService } from './delivery-artifact.service';
|
||||
import { DeliveryArtifactRepository } from './delivery-artifact.repository';
|
||||
@@ -34,7 +36,7 @@ describe('BookModule', () => {
|
||||
});
|
||||
|
||||
it('registers expected controller/providers/exports', () => {
|
||||
expect(Reflect.getMetadata('controllers', BookModule)).toEqual([BookController, ReadingAttemptController]);
|
||||
expect(Reflect.getMetadata('controllers', BookModule)).toEqual([BookController, ReadingAttemptController, ReadingProgressController]);
|
||||
expect(Reflect.getMetadata('providers', BookModule)).toEqual([
|
||||
BookService,
|
||||
BookRepository,
|
||||
@@ -48,6 +50,7 @@ describe('BookModule', () => {
|
||||
DeliveryArtifactRepository,
|
||||
DeliverySelectionService,
|
||||
DeliveryHashRegistrationService,
|
||||
ReadingProgressService,
|
||||
]);
|
||||
// AudiolessEpubService is exported so the Kobo download path can strip narration itself,
|
||||
// without routing through BookService and its RequestUser-based access checks.
|
||||
|
||||
@@ -31,6 +31,8 @@ import { BookRepository } from './book.repository';
|
||||
import { BookService } from './book.service';
|
||||
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
|
||||
import { ReadingAttemptController } from './reading-attempt.controller';
|
||||
import { ReadingProgressController } from './reading-progress.controller';
|
||||
import { ReadingProgressService } from './reading-progress.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -52,7 +54,7 @@ import { ReadingAttemptController } from './reading-attempt.controller';
|
||||
AchievementModule,
|
||||
PositionConverterModule,
|
||||
],
|
||||
controllers: [BookController, ReadingAttemptController],
|
||||
controllers: [BookController, ReadingAttemptController, ReadingProgressController],
|
||||
providers: [
|
||||
BookService,
|
||||
BookRepository,
|
||||
@@ -66,6 +68,7 @@ import { ReadingAttemptController } from './reading-attempt.controller';
|
||||
DeliveryArtifactRepository,
|
||||
DeliverySelectionService,
|
||||
DeliveryHashRegistrationService,
|
||||
ReadingProgressService,
|
||||
],
|
||||
exports: [BookService, BookReadService, BookQueryBuilder, AudiolessEpubService, DeliveryArtifactService, DeliverySelectionService],
|
||||
})
|
||||
|
||||
@@ -1700,6 +1700,9 @@ export class BookRepository {
|
||||
koboLocationValue: readingProgress.koboLocationValue,
|
||||
koboContentSourceProgressPercent: readingProgress.koboContentSourceProgressPercent,
|
||||
koreaderProgress: readingProgress.koreaderProgress,
|
||||
narrationPercentage: readingProgress.narrationPercentage,
|
||||
narrationUpdatedAt: readingProgress.narrationUpdatedAt,
|
||||
textUpdatedAt: readingProgress.textUpdatedAt,
|
||||
updatedAt: readingProgress.updatedAt,
|
||||
lastReadAt: readingProgress.lastReadAt,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { READING_PROGRESS_MAX_WRITES } from '@bookorbit/types';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ReadingProgressQueryDto, ReadingProgressWritesDto } from './reading-progress.dto';
|
||||
|
||||
async function errorsFor<T extends object>(cls: new () => T, value: Record<string, unknown>) {
|
||||
return validate(plainToInstance(cls, value), { whitelist: true, forbidNonWhitelisted: true });
|
||||
}
|
||||
|
||||
const write = {
|
||||
fileId: 64,
|
||||
kind: 'text',
|
||||
percentage: 7.99,
|
||||
editedAt: '2026-10-22T09:30:00.123Z',
|
||||
text: { cfi: 'epubcfi(/6/22!/4/108/1:0)', page: null },
|
||||
narration: { seconds: 812.4, fragment: 'c9R.xhtml#s12', sectionIndex: 9 },
|
||||
kobo: { source: 'OEBPS/c9R.xhtml', type: null, value: null, contentPercent: 50 },
|
||||
koreader: null,
|
||||
};
|
||||
|
||||
describe('Reading progress DTO validation', () => {
|
||||
it('accepts a full write and a bare one', async () => {
|
||||
expect(await errorsFor(ReadingProgressWritesDto, { writes: [write] })).toHaveLength(0);
|
||||
expect(await errorsFor(ReadingProgressWritesDto, { writes: [{ fileId: 64, percentage: 0 }] })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an empty batch, an oversized one, and a file named twice', async () => {
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [] })).length).toBeGreaterThan(0);
|
||||
const tooMany = Array.from({ length: READING_PROGRESS_MAX_WRITES + 1 }, (_, i) => ({ fileId: i + 1, percentage: 1 }));
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: tooMany })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [write, { ...write, percentage: 9 }] })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects an unknown kind, an out-of-range percentage and a loose edit time', async () => {
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, kind: 'audio' }] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, percentage: 101 }] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, editedAt: 'yesterday' }] })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects fields the grouped shape does not have, at either level', async () => {
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, cfi: 'epubcfi(/6/2)' }] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, text: { pageNumber: 3 } }] })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('holds each string to its column', async () => {
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, text: { cfi: 'x'.repeat(2001) } }] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, kobo: { type: 'x'.repeat(65) } }] })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reads query ids as numbers', async () => {
|
||||
const query = plainToInstance(ReadingProgressQueryDto, { fileId: '64' });
|
||||
expect(await validate(query)).toHaveLength(0);
|
||||
expect(query.fileId).toBe(64);
|
||||
expect((await errorsFor(ReadingProgressQueryDto, { bookId: 'abc' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { READING_POSITION_KINDS, READING_PROGRESS_MAX_WRITES, type ReadingPositionKind } from '@bookorbit/types';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ReadingProgressQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
fileId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
bookId?: number;
|
||||
}
|
||||
|
||||
// Each string is held to its column, so an oversized one is a 400 rather than a database error.
|
||||
export class ReadingTextWriteDto {
|
||||
@ValidateIf((o: ReadingTextWriteDto) => o.cfi != null)
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
cfi?: string | null;
|
||||
|
||||
@ValidateIf((o: ReadingTextWriteDto) => o.page != null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(2147483647)
|
||||
page?: number | null;
|
||||
}
|
||||
|
||||
export class ReadingNarrationWriteDto {
|
||||
@ValidateIf((o: ReadingNarrationWriteDto) => o.seconds != null)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
seconds?: number | null;
|
||||
|
||||
@ValidateIf((o: ReadingNarrationWriteDto) => o.fragment != null)
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
fragment?: string | null;
|
||||
|
||||
@ValidateIf((o: ReadingNarrationWriteDto) => o.sectionIndex != null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(2147483647)
|
||||
sectionIndex?: number | null;
|
||||
}
|
||||
|
||||
export class ReadingKoboWriteDto {
|
||||
@ValidateIf((o: ReadingKoboWriteDto) => o.source != null)
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
source?: string | null;
|
||||
|
||||
@ValidateIf((o: ReadingKoboWriteDto) => o.type != null)
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
type?: string | null;
|
||||
|
||||
@ValidateIf((o: ReadingKoboWriteDto) => o.value != null)
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
value?: string | null;
|
||||
|
||||
@ValidateIf((o: ReadingKoboWriteDto) => o.contentPercent != null)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
contentPercent?: number | null;
|
||||
}
|
||||
|
||||
export class ReadingProgressWriteDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
fileId!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(READING_POSITION_KINDS)
|
||||
kind?: ReadingPositionKind;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
percentage!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
editedAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ReadingTextWriteDto)
|
||||
text?: ReadingTextWriteDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ReadingNarrationWriteDto)
|
||||
narration?: ReadingNarrationWriteDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ReadingKoboWriteDto)
|
||||
kobo?: ReadingKoboWriteDto;
|
||||
|
||||
@ValidateIf((o: ReadingProgressWriteDto) => o.koreader != null)
|
||||
@IsString()
|
||||
koreader?: string | null;
|
||||
}
|
||||
|
||||
export class ReadingProgressWritesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(READING_PROGRESS_MAX_WRITES)
|
||||
@ArrayUnique((write: ReadingProgressWriteDto) => write.fileId)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ReadingProgressWriteDto)
|
||||
writes!: ReadingProgressWriteDto[];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { ReadingProgressController } from './reading-progress.controller';
|
||||
|
||||
const user = { id: 7 } as RequestUser;
|
||||
|
||||
function makeController() {
|
||||
const progress = {
|
||||
forFile: vi.fn().mockResolvedValue({ items: [] }),
|
||||
forBook: vi.fn().mockResolvedValue({ items: [] }),
|
||||
save: vi.fn().mockResolvedValue({ results: [] }),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return { controller: new ReadingProgressController(progress as never), progress };
|
||||
}
|
||||
|
||||
describe('ReadingProgressController', () => {
|
||||
it('reads one file or one book', async () => {
|
||||
const { controller, progress } = makeController();
|
||||
await controller.list({ fileId: 64 }, user);
|
||||
await controller.list({ bookId: 60 }, user);
|
||||
expect(progress.forFile).toHaveBeenCalledWith(64, user);
|
||||
expect(progress.forBook).toHaveBeenCalledWith(60, user);
|
||||
});
|
||||
|
||||
it('rejects a read that names both a file and a book, or neither', () => {
|
||||
const { controller } = makeController();
|
||||
expect(() => controller.list({ fileId: 64, bookId: 60 }, user)).toThrow(BadRequestException);
|
||||
expect(() => controller.list({}, user)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('passes the writes and the caller through', async () => {
|
||||
const { controller, progress } = makeController();
|
||||
const writes = [{ fileId: 64, percentage: 8 }];
|
||||
await controller.save({ writes }, user);
|
||||
expect(progress.save).toHaveBeenCalledWith(writes, user);
|
||||
});
|
||||
|
||||
it('clears one file for the caller', async () => {
|
||||
const { controller, progress } = makeController();
|
||||
await controller.clear(64, user);
|
||||
expect(progress.clear).toHaveBeenCalledWith(64, user);
|
||||
});
|
||||
|
||||
it('requires no permission, since every route touches only the caller position', () => {
|
||||
for (const route of [
|
||||
ReadingProgressController.prototype.list,
|
||||
ReadingProgressController.prototype.save,
|
||||
ReadingProgressController.prototype.clear,
|
||||
]) {
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, route)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Put, Query } from '@nestjs/common';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { ReadingProgressQueryDto, ReadingProgressWritesDto } from './dto/reading-progress.dto';
|
||||
import { ReadingProgressService } from './reading-progress.service';
|
||||
|
||||
@Controller('reading-progress')
|
||||
export class ReadingProgressController {
|
||||
constructor(private readonly progress: ReadingProgressService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ReadingProgressQueryDto, @CurrentUser() user: RequestUser) {
|
||||
if (query.fileId != null && query.bookId == null) return this.progress.forFile(query.fileId, user);
|
||||
if (query.bookId != null && query.fileId == null) return this.progress.forBook(query.bookId, user);
|
||||
throw new BadRequestException('Pass exactly one of fileId or bookId');
|
||||
}
|
||||
|
||||
@Put()
|
||||
save(@Body() dto: ReadingProgressWritesDto, @CurrentUser() user: RequestUser) {
|
||||
return this.progress.save(dto.writes, user);
|
||||
}
|
||||
|
||||
@Delete(':fileId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async clear(@Param('fileId', ParseIntPipe) fileId: number, @CurrentUser() user: RequestUser) {
|
||||
await this.progress.clear(fileId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { ReadingProgressService, toReadingProgressEntry } from './reading-progress.service';
|
||||
|
||||
const user = { id: 7 } as RequestUser;
|
||||
|
||||
function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
percentage: 42.5,
|
||||
cfi: 'epubcfi(/6/22!/4/108/1:0)',
|
||||
pageNumber: null,
|
||||
positionSeconds: 812.4,
|
||||
mediaOverlayFragment: 'c9R.xhtml#s12',
|
||||
mediaOverlaySectionIndex: 9,
|
||||
narrationPercentage: 40,
|
||||
narrationUpdatedAt: new Date('2026-10-20T10:00:00.000Z'),
|
||||
textUpdatedAt: new Date('2026-10-21T10:00:00.000Z'),
|
||||
koboLocationSource: 'OEBPS/c9R.xhtml',
|
||||
koboLocationType: 'KoboSpan',
|
||||
koboLocationValue: 'kobo.12.3',
|
||||
koboContentSourceProgressPercent: 50,
|
||||
koreaderProgress: '/body/DocFragment[10]/body/p[3]',
|
||||
updatedAt: new Date('2026-10-21T10:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
const books = {
|
||||
getProgress: vi.fn().mockResolvedValue(storedRow()),
|
||||
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
|
||||
verifyFileAccess: vi.fn().mockResolvedValue({ id: 64 }),
|
||||
saveProgress: vi.fn().mockResolvedValue(undefined),
|
||||
clearFileProgress: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const repo = {
|
||||
findProgress: vi.fn().mockResolvedValue(storedRow()),
|
||||
findProgressByBook: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
return { service: new ReadingProgressService(books as never, repo as never), books, repo };
|
||||
}
|
||||
|
||||
describe('toReadingProgressEntry', () => {
|
||||
it('groups a stored row by the position each field belongs to', () => {
|
||||
expect(toReadingProgressEntry(64, storedRow())).toEqual({
|
||||
fileId: 64,
|
||||
percentage: 42.5,
|
||||
updatedAt: '2026-10-21T10:00:00.000Z',
|
||||
text: { cfi: 'epubcfi(/6/22!/4/108/1:0)', page: null, updatedAt: '2026-10-21T10:00:00.000Z' },
|
||||
narration: { seconds: 812.4, fragment: 'c9R.xhtml#s12', sectionIndex: 9, percentage: 40, updatedAt: '2026-10-20T10:00:00.000Z' },
|
||||
kobo: { source: 'OEBPS/c9R.xhtml', type: 'KoboSpan', value: 'kobo.12.3', contentPercent: 50 },
|
||||
koreader: '/body/DocFragment[10]/body/p[3]',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers an empty position for a file nothing was saved for', () => {
|
||||
const entry = toReadingProgressEntry(64, null);
|
||||
expect(entry.percentage).toBe(0);
|
||||
expect(entry.updatedAt).toBeNull();
|
||||
expect(entry.text).toEqual({ cfi: null, page: null, updatedAt: null });
|
||||
expect(entry.narration.fragment).toBeNull();
|
||||
expect(entry.kobo.value).toBeNull();
|
||||
expect(entry.koreader).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReadingProgressService', () => {
|
||||
it('reads one file through the access-checked lookup', async () => {
|
||||
const { service, books } = makeService();
|
||||
const result = await service.forFile(64, user);
|
||||
expect(books.getProgress).toHaveBeenCalledWith(7, 64, user);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.fileId).toBe(64);
|
||||
});
|
||||
|
||||
it('checks book access before listing every file of the book', async () => {
|
||||
const { service, books, repo } = makeService();
|
||||
repo.findProgressByBook.mockResolvedValue([
|
||||
{ fileId: 64, ...storedRow() },
|
||||
{ fileId: 65, ...storedRow({ percentage: null, cfi: null, updatedAt: null, textUpdatedAt: null }) },
|
||||
]);
|
||||
const result = await service.forBook(60, user);
|
||||
expect(books.verifyBookAccess).toHaveBeenCalledWith(60, user);
|
||||
expect(repo.findProgressByBook).toHaveBeenCalledWith(7, 60);
|
||||
expect(result.items.map((item) => [item.fileId, item.percentage])).toEqual([
|
||||
[64, 42.5],
|
||||
[65, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves a write through the existing progress pipeline with its fields mapped back', async () => {
|
||||
const { service, books } = makeService();
|
||||
const result = await service.save(
|
||||
[
|
||||
{
|
||||
fileId: 64,
|
||||
kind: 'narration',
|
||||
percentage: 8,
|
||||
text: { cfi: 'epubcfi(/6/2)', page: null },
|
||||
narration: { seconds: 12, fragment: 'a.xhtml#s1', sectionIndex: 2 },
|
||||
kobo: { source: 'a.xhtml', type: 'KoboSpan', value: 'kobo.1.1', contentPercent: 10 },
|
||||
koreader: '/body/p[1]',
|
||||
},
|
||||
],
|
||||
user,
|
||||
);
|
||||
expect(books.saveProgress).toHaveBeenCalledWith(
|
||||
7,
|
||||
64,
|
||||
{
|
||||
source: 'narration',
|
||||
percentage: 8,
|
||||
cfi: 'epubcfi(/6/2)',
|
||||
pageNumber: null,
|
||||
positionSeconds: 12,
|
||||
mediaOverlayFragment: 'a.xhtml#s1',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
koboLocationSource: 'a.xhtml',
|
||||
koboLocationType: 'KoboSpan',
|
||||
koboLocationValue: 'kobo.1.1',
|
||||
koboContentSourceProgressPercent: 10,
|
||||
koreaderProgress: '/body/p[1]',
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(result.results).toEqual([expect.objectContaining({ fileId: 64, status: 'saved' })]);
|
||||
});
|
||||
|
||||
// The old route kept the stored narration marker when the fields were left out and cleared it
|
||||
// only on an explicit null. The grouped write has to keep both meanings apart.
|
||||
it('keeps the narration marker when the write leaves it out, and clears it on an explicit null', async () => {
|
||||
const { service, books } = makeService();
|
||||
await service.save([{ fileId: 64, percentage: 8, text: { cfi: 'epubcfi(/6/2)' } }], user);
|
||||
const omitted = books.saveProgress.mock.calls[0]![2];
|
||||
expect(omitted.positionSeconds).toBeUndefined();
|
||||
expect(omitted.mediaOverlayFragment).toBeUndefined();
|
||||
expect(omitted.mediaOverlaySectionIndex).toBeUndefined();
|
||||
|
||||
await service.save([{ fileId: 64, percentage: 8, narration: { seconds: null, fragment: null, sectionIndex: null } }], user);
|
||||
const cleared = books.saveProgress.mock.calls[1]![2];
|
||||
expect(cleared.positionSeconds).toBeNull();
|
||||
expect(cleared.mediaOverlayFragment).toBeNull();
|
||||
expect(cleared.mediaOverlaySectionIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the stored position instead of saving a write made before it', async () => {
|
||||
const { service, books } = makeService();
|
||||
const result = await service.save([{ fileId: 64, percentage: 8, editedAt: '2026-10-21T09:59:59.000Z' }], user);
|
||||
expect(books.saveProgress).not.toHaveBeenCalled();
|
||||
expect(result.results[0]).toEqual({ fileId: 64, status: 'superseded', progress: toReadingProgressEntry(64, storedRow()) });
|
||||
});
|
||||
|
||||
it('saves a write made at or after the stored position', async () => {
|
||||
const { service, books } = makeService();
|
||||
await service.save([{ fileId: 64, percentage: 8, editedAt: '2026-10-21T10:00:00.000Z' }], user);
|
||||
await service.save([{ fileId: 64, percentage: 9, editedAt: '2026-10-21T10:00:01.000Z' }], user);
|
||||
expect(books.saveProgress).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('saves a write with an edit time when nothing was stored yet', async () => {
|
||||
const { service, books, repo } = makeService();
|
||||
repo.findProgress.mockResolvedValueOnce(null);
|
||||
await service.save([{ fileId: 64, percentage: 8, editedAt: '2026-10-21T09:00:00.000Z' }], user);
|
||||
expect(books.saveProgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('writes nothing when any file in the batch is out of reach', async () => {
|
||||
const { service, books } = makeService();
|
||||
books.verifyFileAccess.mockResolvedValueOnce({ id: 64 }).mockRejectedValueOnce(new NotFoundException());
|
||||
await expect(
|
||||
service.save(
|
||||
[
|
||||
{ fileId: 64, percentage: 8 },
|
||||
{ fileId: 65, percentage: 9 },
|
||||
],
|
||||
user,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(books.saveProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers results in the order the writes were sent', async () => {
|
||||
const { service } = makeService();
|
||||
const result = await service.save(
|
||||
[
|
||||
{ fileId: 65, percentage: 8 },
|
||||
{ fileId: 64, percentage: 9 },
|
||||
],
|
||||
user,
|
||||
);
|
||||
expect(result.results.map((entry) => entry.fileId)).toEqual([65, 64]);
|
||||
});
|
||||
|
||||
it('clears one file through the existing reset', async () => {
|
||||
const { service, books } = makeService();
|
||||
await service.clear(64, user);
|
||||
expect(books.clearFileProgress).toHaveBeenCalledWith(7, 64, user);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ReadingProgressEntry, ReadingProgressListResponse, ReadingProgressWriteResponse, ReadingProgressWriteResult } from '@bookorbit/types';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookRepository } from './book.repository';
|
||||
import { BookService } from './book.service';
|
||||
import type { ReadingProgressWriteDto } from './dto/reading-progress.dto';
|
||||
import type { SaveProgressDto } from './dto/save-progress.dto';
|
||||
|
||||
/** A stored progress row, or the left-joined half of one for a file nothing was saved for. */
|
||||
interface ProgressRow {
|
||||
percentage: number | null;
|
||||
cfi: string | null;
|
||||
pageNumber: number | null;
|
||||
positionSeconds: number | null;
|
||||
mediaOverlayFragment: string | null;
|
||||
mediaOverlaySectionIndex: number | null;
|
||||
narrationPercentage: number | null;
|
||||
narrationUpdatedAt: Date | null;
|
||||
textUpdatedAt: Date | null;
|
||||
koboLocationSource: string | null;
|
||||
koboLocationType: string | null;
|
||||
koboLocationValue: string | null;
|
||||
koboContentSourceProgressPercent: number | null;
|
||||
koreaderProgress: string | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
function iso(value: Date | null | undefined): string | null {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
export function toReadingProgressEntry(fileId: number, row: ProgressRow | null): ReadingProgressEntry {
|
||||
return {
|
||||
fileId,
|
||||
percentage: row?.percentage ?? 0,
|
||||
updatedAt: iso(row?.updatedAt),
|
||||
text: { cfi: row?.cfi ?? null, page: row?.pageNumber ?? null, updatedAt: iso(row?.textUpdatedAt) },
|
||||
narration: {
|
||||
seconds: row?.positionSeconds ?? null,
|
||||
fragment: row?.mediaOverlayFragment ?? null,
|
||||
sectionIndex: row?.mediaOverlaySectionIndex ?? null,
|
||||
percentage: row?.narrationPercentage ?? null,
|
||||
updatedAt: iso(row?.narrationUpdatedAt),
|
||||
},
|
||||
kobo: {
|
||||
source: row?.koboLocationSource ?? null,
|
||||
type: row?.koboLocationType ?? null,
|
||||
value: row?.koboLocationValue ?? null,
|
||||
contentPercent: row?.koboContentSourceProgressPercent ?? null,
|
||||
},
|
||||
koreader: row?.koreaderProgress ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Narration fields stay undefined when the write leaves them out, which keeps the stored marker. */
|
||||
function toSaveProgressDto(write: ReadingProgressWriteDto): SaveProgressDto {
|
||||
return {
|
||||
source: write.kind,
|
||||
percentage: write.percentage,
|
||||
cfi: write.text?.cfi,
|
||||
pageNumber: write.text?.page,
|
||||
positionSeconds: write.narration?.seconds,
|
||||
mediaOverlayFragment: write.narration?.fragment,
|
||||
mediaOverlaySectionIndex: write.narration?.sectionIndex,
|
||||
koboLocationSource: write.kobo?.source,
|
||||
koboLocationType: write.kobo?.type,
|
||||
koboLocationValue: write.kobo?.value,
|
||||
koboContentSourceProgressPercent: write.kobo?.contentPercent,
|
||||
koreaderProgress: write.koreader,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReadingProgressService {
|
||||
constructor(
|
||||
private readonly books: BookService,
|
||||
private readonly bookRepo: BookRepository,
|
||||
) {}
|
||||
|
||||
async forFile(fileId: number, user: RequestUser): Promise<ReadingProgressListResponse> {
|
||||
const row = await this.books.getProgress(user.id, fileId, user);
|
||||
return { items: [toReadingProgressEntry(fileId, row)] };
|
||||
}
|
||||
|
||||
async forBook(bookId: number, user: RequestUser): Promise<ReadingProgressListResponse> {
|
||||
await this.books.verifyBookAccess(bookId, user);
|
||||
const rows = await this.bookRepo.findProgressByBook(user.id, bookId);
|
||||
return { items: rows.map((row) => toReadingProgressEntry(row.fileId, row)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file is checked before any is written, so a batch naming one the caller cannot open
|
||||
* changes nothing. Saving a position twice is harmless, so a batch that fails partway can be sent
|
||||
* again whole.
|
||||
*/
|
||||
async save(writes: ReadingProgressWriteDto[], user: RequestUser): Promise<ReadingProgressWriteResponse> {
|
||||
for (const write of writes) await this.books.verifyFileAccess(write.fileId, user);
|
||||
const results: ReadingProgressWriteResult[] = [];
|
||||
for (const write of writes) results.push(await this.saveOne(write, user));
|
||||
return { results };
|
||||
}
|
||||
|
||||
async clear(fileId: number, user: RequestUser): Promise<void> {
|
||||
await this.books.clearFileProgress(user.id, fileId, user);
|
||||
}
|
||||
|
||||
private async saveOne(write: ReadingProgressWriteDto, user: RequestUser): Promise<ReadingProgressWriteResult> {
|
||||
if (write.editedAt) {
|
||||
const stored = await this.bookRepo.findProgress(user.id, write.fileId);
|
||||
if (stored?.updatedAt && stored.updatedAt.getTime() > Date.parse(write.editedAt)) {
|
||||
return { fileId: write.fileId, status: 'superseded', progress: toReadingProgressEntry(write.fileId, stored) };
|
||||
}
|
||||
}
|
||||
await this.books.saveProgress(user.id, write.fileId, toSaveProgressDto(write), user);
|
||||
const saved = await this.bookRepo.findProgress(user.id, write.fileId);
|
||||
return { fileId: write.fileId, status: 'saved', progress: toReadingProgressEntry(write.fileId, saved) };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"totalRoutes": 701,
|
||||
"totalRoutes": 704,
|
||||
"byPermission": {
|
||||
"(none)": 294,
|
||||
"(none)": 297,
|
||||
"Permission.ManageMetadataConfig": 41,
|
||||
"Permission.EmailSend": 34,
|
||||
"Permission.LibraryEditMetadata": 54,
|
||||
|
||||
@@ -1210,5 +1210,41 @@
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book/reading-progress.controller.ts",
|
||||
"className": "ReadingProgressController",
|
||||
"methodName": "list",
|
||||
"httpMethod": "GET",
|
||||
"path": "/reading-progress",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book/reading-progress.controller.ts",
|
||||
"className": "ReadingProgressController",
|
||||
"methodName": "save",
|
||||
"httpMethod": "PUT",
|
||||
"path": "/reading-progress",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book/reading-progress.controller.ts",
|
||||
"className": "ReadingProgressController",
|
||||
"methodName": "clear",
|
||||
"httpMethod": "DELETE",
|
||||
"path": "/reading-progress/:fileId",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user