cyreader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/bundle/server/dist/app.js +23 -0
  2. package/bundle/server/dist/app.test.js +9 -0
  3. package/bundle/server/dist/config/defaults.js +7 -0
  4. package/bundle/server/dist/config/paths.js +18 -0
  5. package/bundle/server/dist/config/schema.js +84 -0
  6. package/bundle/server/dist/config/schema.test.js +76 -0
  7. package/bundle/server/dist/config/service.js +38 -0
  8. package/bundle/server/dist/config/storage.js +54 -0
  9. package/bundle/server/dist/config/storage.test.js +79 -0
  10. package/bundle/server/dist/index.js +28 -0
  11. package/bundle/server/dist/library/comic-url.js +4 -0
  12. package/bundle/server/dist/library/cover.js +44 -0
  13. package/bundle/server/dist/library/errors.js +12 -0
  14. package/bundle/server/dist/library/files.js +35 -0
  15. package/bundle/server/dist/library/files.test.js +29 -0
  16. package/bundle/server/dist/library/id.js +6 -0
  17. package/bundle/server/dist/library/novel-url.js +4 -0
  18. package/bundle/server/dist/library/scan.js +132 -0
  19. package/bundle/server/dist/library/scan.test.js +252 -0
  20. package/bundle/server/dist/library/service.js +87 -0
  21. package/bundle/server/dist/library/service.test.js +92 -0
  22. package/bundle/server/dist/library/test-images.js +23 -0
  23. package/bundle/server/dist/library/text-encoding.js +33 -0
  24. package/bundle/server/dist/library/text-encoding.test.js +42 -0
  25. package/bundle/server/dist/library/types.js +1 -0
  26. package/bundle/server/dist/library-static.js +75 -0
  27. package/bundle/server/dist/library-static.test.js +81 -0
  28. package/bundle/server/dist/listen-config.js +23 -0
  29. package/bundle/server/dist/listen-config.test.js +50 -0
  30. package/bundle/server/dist/reading/progress/merge.js +32 -0
  31. package/bundle/server/dist/reading/progress/schema.js +66 -0
  32. package/bundle/server/dist/reading/progress/schema.test.js +39 -0
  33. package/bundle/server/dist/reading/progress/service.js +81 -0
  34. package/bundle/server/dist/reading/progress/service.test.js +104 -0
  35. package/bundle/server/dist/reading/progress/storage.js +44 -0
  36. package/bundle/server/dist/reading/recent/schema.js +16 -0
  37. package/bundle/server/dist/reading/recent/service.js +54 -0
  38. package/bundle/server/dist/reading/recent/service.test.js +84 -0
  39. package/bundle/server/dist/reading/recent/storage.js +42 -0
  40. package/bundle/server/dist/reading/recent/storage.test.js +46 -0
  41. package/bundle/server/dist/routes/config.js +52 -0
  42. package/bundle/server/dist/routes/config.test.js +147 -0
  43. package/bundle/server/dist/routes/library.js +31 -0
  44. package/bundle/server/dist/routes/library.test.js +125 -0
  45. package/bundle/server/dist/routes/reading.js +66 -0
  46. package/bundle/server/dist/routes/reading.test.js +135 -0
  47. package/bundle/server/dist/web-static.js +31 -0
  48. package/bundle/server/dist/web-static.test.js +92 -0
  49. package/bundle/web/dist/assets/_shell-DPQLbvwD.js +5 -0
  50. package/bundle/web/dist/assets/_shell-gkEQU-gF.js +1 -0
  51. package/bundle/web/dist/assets/comic._id-BjwrciUv.js +1 -0
  52. package/bundle/web/dist/assets/dist-Da_WaNYN.js +1 -0
  53. package/bundle/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  54. package/bundle/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  55. package/bundle/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  56. package/bundle/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  57. package/bundle/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  58. package/bundle/web/dist/assets/image-BZKAkGUy.js +1 -0
  59. package/bundle/web/dist/assets/index-BpBtuR9k.css +2 -0
  60. package/bundle/web/dist/assets/index-CaXGEWDb.js +10 -0
  61. package/bundle/web/dist/assets/input-DcKYfXao.js +41 -0
  62. package/bundle/web/dist/assets/library-Dw1qyl0v.js +1 -0
  63. package/bundle/web/dist/assets/mutation-CwzbY9ri.js +1 -0
  64. package/bundle/web/dist/assets/novel._id-DsL8say-.js +4 -0
  65. package/bundle/web/dist/assets/query-keys-CDsPIOEO.js +1 -0
  66. package/bundle/web/dist/assets/reading-BseNE7P3.js +1 -0
  67. package/bundle/web/dist/assets/reading-progress-BcmbUd_d.js +1 -0
  68. package/bundle/web/dist/assets/save-reading-progress-AuKXiup6.js +1 -0
  69. package/bundle/web/dist/assets/scroll-area-Bzi-DiTy.js +1 -0
  70. package/bundle/web/dist/assets/settings-2-CCv0KiQI.js +1 -0
  71. package/bundle/web/dist/assets/settings-2wiLHSOF.js +1 -0
  72. package/bundle/web/dist/assets/shell-context-BT0BT8PF.js +1 -0
  73. package/bundle/web/dist/assets/toggle-group-DDKezi4R.js +1 -0
  74. package/bundle/web/dist/assets/useNavigate-BMyovDLu.js +1 -0
  75. package/bundle/web/dist/assets/utils-BmM72imW.js +1 -0
  76. package/bundle/web/dist/favicon.svg +1 -0
  77. package/bundle/web/dist/index.html +17 -0
  78. package/bundle/web/dist/novel-default-cover.png +0 -0
  79. package/dist/cli.js +40 -0
  80. package/dist/parse-options.js +30 -0
  81. package/dist/parse-options.test.js +26 -0
  82. package/dist/paths.js +11 -0
  83. package/dist/paths.test.js +13 -0
  84. package/dist/spawn-server.js +40 -0
  85. package/dist/spawn-server.test.js +46 -0
  86. package/package.json +32 -0
@@ -0,0 +1,75 @@
1
+ import { statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { serveStatic } from '@hono/node-server/serve-static';
4
+ const CACHE_CONTROL = 'public, max-age=86400';
5
+ let libraryDirectories = [];
6
+ function buildEtag(stats) {
7
+ return `"${stats.size}-${Math.trunc(stats.mtimeMs)}"`;
8
+ }
9
+ function resolveStaticFilePath(requestPath, directoryId, root) {
10
+ let filename;
11
+ try {
12
+ filename = decodeURI(requestPath);
13
+ if (/(?:^|[/\\])\.{1,2}(?:$|[/\\])|[/\\]{2,}/.test(filename)) {
14
+ return null;
15
+ }
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ const relativePath = filename.replace(new RegExp(`^/static/${directoryId}`), '');
21
+ if (!relativePath) {
22
+ return null;
23
+ }
24
+ const filePath = path.join(root, relativePath);
25
+ let stats;
26
+ try {
27
+ stats = statSync(filePath);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ if (!stats.isFile()) {
33
+ return null;
34
+ }
35
+ return filePath;
36
+ }
37
+ function applyCacheHeaders(c, filePath) {
38
+ const stats = statSync(filePath);
39
+ c.header('Cache-Control', CACHE_CONTROL);
40
+ c.header('ETag', buildEtag(stats));
41
+ c.header('Last-Modified', stats.mtime.toUTCString());
42
+ if (path.extname(filePath).toLowerCase() === '.txt') {
43
+ c.header('Content-Type', 'text/plain; charset=utf-8');
44
+ }
45
+ }
46
+ export function refreshLibraryStaticRoutes(config) {
47
+ libraryDirectories = [...config.comicDirectories, ...config.novelDirectories];
48
+ }
49
+ export function mountLibraryStaticRoutes(app, config) {
50
+ refreshLibraryStaticRoutes(config);
51
+ app.use('/static/:directoryId/*', async (c, next) => {
52
+ const directoryId = c.req.param('directoryId');
53
+ const entry = libraryDirectories.find((directory) => directory.id === directoryId);
54
+ if (!entry) {
55
+ return c.notFound();
56
+ }
57
+ const filePath = resolveStaticFilePath(c.req.path, directoryId, entry.path);
58
+ if (!filePath) {
59
+ return next();
60
+ }
61
+ const stats = statSync(filePath);
62
+ const etag = buildEtag(stats);
63
+ const ifNoneMatch = c.req.header('If-None-Match');
64
+ if (ifNoneMatch === etag) {
65
+ applyCacheHeaders(c, filePath);
66
+ return c.body(null, 304);
67
+ }
68
+ applyCacheHeaders(c, filePath);
69
+ const handler = serveStatic({
70
+ root: entry.path,
71
+ rewriteRequestPath: (requestPath) => requestPath.replace(new RegExp(`^/static/${directoryId}`), ''),
72
+ });
73
+ return handler(c, next);
74
+ });
75
+ }
@@ -0,0 +1,81 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { Hono } from 'hono';
6
+ import { mountLibraryStaticRoutes, refreshLibraryStaticRoutes } from './library-static.js';
7
+ describe('library static routes', () => {
8
+ let tempDir;
9
+ let comicRoot;
10
+ let novelRoot;
11
+ let mangaDir;
12
+ let app;
13
+ beforeEach(async () => {
14
+ tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-library-static-'));
15
+ comicRoot = path.join(tempDir, 'comics');
16
+ novelRoot = path.join(tempDir, 'novels');
17
+ mangaDir = path.join(comicRoot, 'manga');
18
+ await mkdir(mangaDir, { recursive: true });
19
+ await mkdir(novelRoot, { recursive: true });
20
+ await writeFile(path.join(mangaDir, '1.jpg'), 'page-one');
21
+ await writeFile(path.join(mangaDir, '2.jpg'), 'page-two');
22
+ await writeFile(path.join(novelRoot, 'book.txt'), 'chapter one\n\nchapter two');
23
+ app = new Hono();
24
+ mountLibraryStaticRoutes(app, {
25
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
26
+ comicDirectories: [{ id: 'comic-root', path: comicRoot }],
27
+ gbkNovelHandling: 'ignore',
28
+ });
29
+ });
30
+ afterEach(async () => {
31
+ await rm(tempDir, { recursive: true, force: true });
32
+ });
33
+ it('serves comic page via /static/{directoryId}/{title}/{filename}', async () => {
34
+ const res = await app.request('/static/comic-root/manga/1.jpg');
35
+ expect(res.status).toBe(200);
36
+ expect(await res.text()).toBe('page-one');
37
+ expect(res.headers.get('cache-control')).toBe('public, max-age=86400');
38
+ expect(res.headers.get('etag')).toMatch(/^"\d+-\d+"$/u);
39
+ expect(res.headers.get('last-modified')).toBeTruthy();
40
+ });
41
+ it('serves novel txt via /static/{directoryId}/{filename}', async () => {
42
+ const res = await app.request('/static/novel-root/book.txt');
43
+ expect(res.status).toBe(200);
44
+ expect(await res.text()).toBe('chapter one\n\nchapter two');
45
+ expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
46
+ expect(res.headers.get('cache-control')).toBe('public, max-age=86400');
47
+ expect(res.headers.get('etag')).toMatch(/^"\d+-\d+"$/u);
48
+ });
49
+ it('returns 304 when If-None-Match matches ETag', async () => {
50
+ const first = await app.request('/static/comic-root/manga/1.jpg');
51
+ const etag = first.headers.get('etag');
52
+ expect(etag).toBeTruthy();
53
+ const second = await app.request('/static/comic-root/manga/1.jpg', {
54
+ headers: { 'If-None-Match': etag },
55
+ });
56
+ expect(second.status).toBe(304);
57
+ expect(second.headers.get('cache-control')).toBe('public, max-age=86400');
58
+ expect(second.headers.get('etag')).toBe(etag);
59
+ expect(await second.text()).toBe('');
60
+ });
61
+ it('returns 404 for unknown static path', async () => {
62
+ const res = await app.request('/static/comic-root/manga/missing.jpg');
63
+ expect(res.status).toBe(404);
64
+ });
65
+ it('refreshes static routes after config change', async () => {
66
+ const newRoot = path.join(tempDir, 'comics-new');
67
+ const newMangaDir = path.join(newRoot, 'series');
68
+ await mkdir(newMangaDir, { recursive: true });
69
+ await writeFile(path.join(newMangaDir, 'cover.png'), 'new-cover');
70
+ refreshLibraryStaticRoutes({
71
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
72
+ comicDirectories: [{ id: 'new-root', path: newRoot }],
73
+ gbkNovelHandling: 'ignore',
74
+ });
75
+ const oldRes = await app.request('/static/comic-root/manga/1.jpg');
76
+ expect(oldRes.status).toBe(404);
77
+ const newRes = await app.request('/static/new-root/series/cover.png');
78
+ expect(newRes.status).toBe(200);
79
+ expect(await newRes.text()).toBe('new-cover');
80
+ });
81
+ });
@@ -0,0 +1,23 @@
1
+ export const DEFAULT_PORT = 4613;
2
+ export function getListenPort() {
3
+ const raw = process.env.CYREADER_PORT;
4
+ if (raw === undefined || raw === '') {
5
+ return DEFAULT_PORT;
6
+ }
7
+ const port = Number(raw);
8
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
9
+ throw new Error(`Invalid CYREADER_PORT: ${raw}`);
10
+ }
11
+ return port;
12
+ }
13
+ export function getListenHostname() {
14
+ const hostname = process.env.CYREADER_HOST;
15
+ if (hostname === undefined || hostname === '') {
16
+ return undefined;
17
+ }
18
+ return hostname;
19
+ }
20
+ export function formatListenUrl(port, hostname) {
21
+ const host = hostname ?? 'localhost';
22
+ return `http://${host}:${port}`;
23
+ }
@@ -0,0 +1,50 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { DEFAULT_PORT, formatListenUrl, getListenHostname, getListenPort, } from './listen-config.js';
3
+ describe('listen config', () => {
4
+ let previousPort;
5
+ let previousHost;
6
+ beforeEach(() => {
7
+ previousPort = process.env.CYREADER_PORT;
8
+ previousHost = process.env.CYREADER_HOST;
9
+ });
10
+ afterEach(() => {
11
+ if (previousPort === undefined) {
12
+ delete process.env.CYREADER_PORT;
13
+ }
14
+ else {
15
+ process.env.CYREADER_PORT = previousPort;
16
+ }
17
+ if (previousHost === undefined) {
18
+ delete process.env.CYREADER_HOST;
19
+ }
20
+ else {
21
+ process.env.CYREADER_HOST = previousHost;
22
+ }
23
+ });
24
+ it('uses the default port when CYREADER_PORT is unset', () => {
25
+ delete process.env.CYREADER_PORT;
26
+ expect(getListenPort()).toBe(DEFAULT_PORT);
27
+ });
28
+ it('reads CYREADER_PORT from the environment', () => {
29
+ process.env.CYREADER_PORT = '8080';
30
+ expect(getListenPort()).toBe(8080);
31
+ });
32
+ it('rejects invalid CYREADER_PORT values', () => {
33
+ process.env.CYREADER_PORT = '0';
34
+ expect(() => getListenPort()).toThrow(/Invalid CYREADER_PORT/);
35
+ });
36
+ it('returns undefined hostname when CYREADER_HOST is unset', () => {
37
+ delete process.env.CYREADER_HOST;
38
+ expect(getListenHostname()).toBeUndefined();
39
+ });
40
+ it('reads CYREADER_HOST from the environment', () => {
41
+ process.env.CYREADER_HOST = '0.0.0.0';
42
+ expect(getListenHostname()).toBe('0.0.0.0');
43
+ });
44
+ it('formats the listen URL with localhost when hostname is unset', () => {
45
+ expect(formatListenUrl(4613)).toBe('http://localhost:4613');
46
+ });
47
+ it('formats the listen URL with the configured hostname', () => {
48
+ expect(formatListenUrl(8080, '0.0.0.0')).toBe('http://0.0.0.0:8080');
49
+ });
50
+ });
@@ -0,0 +1,32 @@
1
+ function novelProgress(entry) {
2
+ return entry?.type === 'novel' ? entry : null;
3
+ }
4
+ function comicProgress(entry) {
5
+ return entry?.type === 'comic' ? entry : null;
6
+ }
7
+ export function attachProgressToListItem(item, progressMap) {
8
+ const entry = progressMap[item.id] ?? null;
9
+ if (item.type === 'novel') {
10
+ return {
11
+ ...item,
12
+ progress: novelProgress(entry),
13
+ };
14
+ }
15
+ return {
16
+ ...item,
17
+ progress: comicProgress(entry),
18
+ };
19
+ }
20
+ export function attachProgressToDetail(detail, progressMap) {
21
+ const entry = progressMap[detail.id] ?? null;
22
+ if (detail.type === 'novel') {
23
+ return {
24
+ ...detail,
25
+ progress: novelProgress(entry),
26
+ };
27
+ }
28
+ return {
29
+ ...detail,
30
+ progress: comicProgress(entry),
31
+ };
32
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+ const comicProgressSchema = z.object({
3
+ type: z.literal('comic'),
4
+ pageIndex: z.number().int().min(0),
5
+ updatedAt: z.string().datetime(),
6
+ completedAt: z.string().datetime().nullable().optional(),
7
+ });
8
+ const novelProgressSchema = z.object({
9
+ type: z.literal('novel'),
10
+ paragraphIndex: z.number().int().min(0),
11
+ updatedAt: z.string().datetime(),
12
+ anchor: z.string().optional(),
13
+ layout: z
14
+ .object({
15
+ paragraphCount: z.number().int().min(0),
16
+ charCount: z.number().int().min(0),
17
+ })
18
+ .optional(),
19
+ completedAt: z.string().datetime().nullable().optional(),
20
+ });
21
+ export const progressEntrySchema = z.discriminatedUnion('type', [
22
+ comicProgressSchema,
23
+ novelProgressSchema,
24
+ ]);
25
+ const progressStoreSchema = z.object({
26
+ items: z.record(z.string(), progressEntrySchema),
27
+ });
28
+ export const comicProgressInputSchema = z.object({
29
+ type: z.literal('comic'),
30
+ pageIndex: z.number().int().min(0),
31
+ completedAt: z.string().datetime().nullable().optional(),
32
+ });
33
+ export const novelProgressInputSchema = z.object({
34
+ type: z.literal('novel'),
35
+ paragraphIndex: z.number().int().min(0),
36
+ anchor: z.string().optional(),
37
+ layout: z
38
+ .object({
39
+ paragraphCount: z.number().int().min(0),
40
+ charCount: z.number().int().min(0),
41
+ })
42
+ .optional(),
43
+ completedAt: z.string().datetime().nullable().optional(),
44
+ });
45
+ export const progressInputSchema = z.discriminatedUnion('type', [
46
+ comicProgressInputSchema,
47
+ novelProgressInputSchema,
48
+ ]);
49
+ export function createEmptyProgressStore() {
50
+ return { items: {} };
51
+ }
52
+ export function parseProgressStore(data) {
53
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
54
+ return createEmptyProgressStore();
55
+ }
56
+ const record = data;
57
+ const rawItems = record.items;
58
+ if (rawItems === undefined) {
59
+ return createEmptyProgressStore();
60
+ }
61
+ const parsed = progressStoreSchema.safeParse({ items: rawItems });
62
+ if (!parsed.success) {
63
+ return createEmptyProgressStore();
64
+ }
65
+ return parsed.data;
66
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseProgressStore, progressInputSchema } from './schema.js';
3
+ describe('progress schema', () => {
4
+ it('parses comic and novel entries', () => {
5
+ const store = parseProgressStore({
6
+ items: {
7
+ comic1: {
8
+ type: 'comic',
9
+ pageIndex: 2,
10
+ updatedAt: '2026-01-01T00:00:00.000Z',
11
+ },
12
+ novel1: {
13
+ type: 'novel',
14
+ paragraphIndex: 10,
15
+ updatedAt: '2026-01-01T00:00:00.000Z',
16
+ anchor: 'chapter',
17
+ layout: { paragraphCount: 100, charCount: 5000 },
18
+ },
19
+ },
20
+ });
21
+ expect(store.items.comic1?.type).toBe('comic');
22
+ expect(store.items.novel1?.type === 'novel' ? store.items.novel1.paragraphIndex : null).toBe(10);
23
+ });
24
+ it('treats missing items as an empty store', () => {
25
+ expect(parseProgressStore({})).toEqual({ items: {} });
26
+ expect(parseProgressStore(null)).toEqual({ items: {} });
27
+ });
28
+ it('validates progress input', () => {
29
+ expect(progressInputSchema.safeParse({
30
+ type: 'comic',
31
+ pageIndex: 0,
32
+ completedAt: null,
33
+ }).success).toBe(true);
34
+ expect(progressInputSchema.safeParse({
35
+ type: 'novel',
36
+ paragraphIndex: 3,
37
+ }).success).toBe(true);
38
+ });
39
+ });
@@ -0,0 +1,81 @@
1
+ import { NotFoundError } from '../../library/errors.js';
2
+ import { ensureProgressStore, readProgressStoreFromDisk, writeProgressStore } from './storage.js';
3
+ export class ProgressService {
4
+ libraryService;
5
+ constructor(libraryService) {
6
+ this.libraryService = libraryService;
7
+ }
8
+ static async create(libraryService) {
9
+ await ensureProgressStore();
10
+ return new ProgressService(libraryService);
11
+ }
12
+ async getProgressMap() {
13
+ const store = await this.pruneStaleEntries();
14
+ return store.items;
15
+ }
16
+ getProgressFromMap(progressMap, itemId) {
17
+ return progressMap[itemId] ?? null;
18
+ }
19
+ async getProgress(itemId) {
20
+ const progressMap = await this.getProgressMap();
21
+ return this.getProgressFromMap(progressMap, itemId);
22
+ }
23
+ async saveProgress(itemId, input) {
24
+ const listItem = this.libraryService.getListItem(itemId);
25
+ if (!listItem) {
26
+ throw new NotFoundError('Item not found');
27
+ }
28
+ if (listItem.type !== input.type) {
29
+ throw new TypeError('Progress type does not match library item type');
30
+ }
31
+ const entry = this.buildEntry(itemId, input);
32
+ const store = await readProgressStoreFromDisk();
33
+ store.items[itemId] = entry;
34
+ await writeProgressStore(store);
35
+ return entry;
36
+ }
37
+ async pruneStaleEntries() {
38
+ const store = await readProgressStoreFromDisk();
39
+ const keptItems = {};
40
+ let hasStale = false;
41
+ for (const [itemId, entry] of Object.entries(store.items)) {
42
+ const listItem = this.libraryService.getListItem(itemId);
43
+ if (!listItem || listItem.type !== entry.type) {
44
+ hasStale = true;
45
+ continue;
46
+ }
47
+ keptItems[itemId] = entry;
48
+ }
49
+ if (hasStale) {
50
+ await writeProgressStore({ items: keptItems });
51
+ return { items: keptItems };
52
+ }
53
+ return store;
54
+ }
55
+ buildEntry(itemId, input) {
56
+ const updatedAt = new Date().toISOString();
57
+ if (input.type === 'comic') {
58
+ const detail = this.libraryService.getItemDetail(itemId);
59
+ if (detail.type !== 'comic') {
60
+ throw new TypeError('Progress type does not match library item type');
61
+ }
62
+ const pageCount = detail.pages.length;
63
+ const pageIndex = pageCount === 0 ? 0 : Math.min(input.pageIndex, pageCount - 1);
64
+ return {
65
+ type: 'comic',
66
+ pageIndex,
67
+ updatedAt,
68
+ ...(input.completedAt !== undefined ? { completedAt: input.completedAt } : {}),
69
+ };
70
+ }
71
+ const paragraphIndex = Math.max(0, input.paragraphIndex);
72
+ return {
73
+ type: 'novel',
74
+ paragraphIndex,
75
+ updatedAt,
76
+ ...(input.anchor !== undefined ? { anchor: input.anchor } : {}),
77
+ ...(input.layout !== undefined ? { layout: input.layout } : {}),
78
+ ...(input.completedAt !== undefined ? { completedAt: input.completedAt } : {}),
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,104 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { NotFoundError } from '../../library/errors.js';
6
+ import { LibraryService } from '../../library/service.js';
7
+ import { ProgressService } from './service.js';
8
+ import { readProgressStoreFromDisk } from './storage.js';
9
+ describe('ProgressService', () => {
10
+ let tempDir;
11
+ let previousConfigDir;
12
+ let novelRoot;
13
+ let comicRoot;
14
+ let mangaDir;
15
+ let libraryService;
16
+ let progressService;
17
+ let novelId;
18
+ let comicId;
19
+ beforeEach(async () => {
20
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
21
+ tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-progress-service-'));
22
+ process.env.CYREADER_CONFIG_DIR = tempDir;
23
+ novelRoot = path.join(tempDir, 'novels');
24
+ comicRoot = path.join(tempDir, 'comics');
25
+ mangaDir = path.join(comicRoot, 'manga');
26
+ await mkdir(novelRoot);
27
+ await mkdir(mangaDir, { recursive: true });
28
+ await writeFile(path.join(novelRoot, 'book.txt'), 'line one\n\nline two');
29
+ await writeFile(path.join(mangaDir, '1.jpg'), '');
30
+ await writeFile(path.join(mangaDir, '2.jpg'), '');
31
+ await writeFile(path.join(mangaDir, '3.jpg'), '');
32
+ libraryService = new LibraryService(() => ({
33
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
34
+ comicDirectories: [{ id: 'comic-root', path: comicRoot }],
35
+ gbkNovelHandling: 'ignore',
36
+ }));
37
+ await libraryService.scan();
38
+ progressService = await ProgressService.create(libraryService);
39
+ const items = libraryService.getState().items;
40
+ novelId = items.find((item) => item.type === 'novel').id;
41
+ comicId = items.find((item) => item.type === 'comic').id;
42
+ });
43
+ afterEach(async () => {
44
+ if (previousConfigDir === undefined) {
45
+ delete process.env.CYREADER_CONFIG_DIR;
46
+ }
47
+ else {
48
+ process.env.CYREADER_CONFIG_DIR = previousConfigDir;
49
+ }
50
+ await rm(tempDir, { recursive: true, force: true });
51
+ });
52
+ it('saves and reads comic progress with clamped page index', async () => {
53
+ const entry = await progressService.saveProgress(comicId, {
54
+ type: 'comic',
55
+ pageIndex: 99,
56
+ });
57
+ expect(entry).toEqual({
58
+ type: 'comic',
59
+ pageIndex: 2,
60
+ updatedAt: expect.any(String),
61
+ });
62
+ const map = await progressService.getProgressMap();
63
+ const comicProgress = map[comicId];
64
+ expect(comicProgress?.type).toBe('comic');
65
+ if (comicProgress?.type === 'comic') {
66
+ expect(comicProgress.pageIndex).toBe(2);
67
+ }
68
+ });
69
+ it('saves novel progress', async () => {
70
+ const entry = await progressService.saveProgress(novelId, {
71
+ type: 'novel',
72
+ paragraphIndex: 4,
73
+ anchor: 'line one',
74
+ layout: { paragraphCount: 2, charCount: 20 },
75
+ });
76
+ expect(entry.type).toBe('novel');
77
+ if (entry.type === 'novel') {
78
+ expect(entry.paragraphIndex).toBe(4);
79
+ expect(entry.anchor).toBe('line one');
80
+ }
81
+ });
82
+ it('throws for unknown item', async () => {
83
+ await expect(progressService.saveProgress('missing', { type: 'novel', paragraphIndex: 0 })).rejects.toBeInstanceOf(NotFoundError);
84
+ });
85
+ it('throws when progress type mismatches item type', async () => {
86
+ await expect(progressService.saveProgress(novelId, { type: 'comic', pageIndex: 0 })).rejects.toBeInstanceOf(TypeError);
87
+ });
88
+ it('prunes stale progress entries', async () => {
89
+ await progressService.saveProgress(novelId, { type: 'novel', paragraphIndex: 1 });
90
+ const store = await readProgressStoreFromDisk();
91
+ store.items.stale = {
92
+ type: 'novel',
93
+ paragraphIndex: 0,
94
+ updatedAt: new Date().toISOString(),
95
+ };
96
+ await import('./storage.js').then(({ writeProgressStore }) => writeProgressStore(store));
97
+ const map = await progressService.getProgressMap();
98
+ expect(map.stale).toBeUndefined();
99
+ const novelProgress = map[novelId];
100
+ if (novelProgress?.type === 'novel') {
101
+ expect(novelProgress.paragraphIndex).toBe(1);
102
+ }
103
+ });
104
+ });
@@ -0,0 +1,44 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { getConfigDir, getProgressFilePath } from '../../config/paths.js';
3
+ import { createEmptyProgressStore, parseProgressStore } from './schema.js';
4
+ async function fileExists(filePath) {
5
+ try {
6
+ await access(filePath);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ export async function writeProgressStore(store) {
14
+ const configDir = getConfigDir();
15
+ const progressFile = getProgressFilePath();
16
+ const normalized = parseProgressStore(store);
17
+ await mkdir(configDir, { recursive: true });
18
+ await writeFile(progressFile, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
19
+ }
20
+ export async function readProgressStoreFromDisk() {
21
+ const progressFile = getProgressFilePath();
22
+ const content = await readFile(progressFile, 'utf-8');
23
+ let data;
24
+ try {
25
+ data = JSON.parse(content);
26
+ }
27
+ catch (error) {
28
+ throw new Error(`Failed to parse progress file: ${progressFile}`, { cause: error });
29
+ }
30
+ return parseProgressStore(data);
31
+ }
32
+ export async function ensureProgressStore() {
33
+ const configDir = getConfigDir();
34
+ const progressFile = getProgressFilePath();
35
+ await mkdir(configDir, { recursive: true });
36
+ if (!(await fileExists(progressFile))) {
37
+ const empty = createEmptyProgressStore();
38
+ await writeProgressStore(empty);
39
+ return empty;
40
+ }
41
+ const store = await readProgressStoreFromDisk();
42
+ await writeProgressStore(store);
43
+ return store;
44
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ export const MAX_RECENT = 7;
3
+ const recentEntrySchema = z.object({
4
+ itemId: z.string().min(1),
5
+ type: z.enum(['novel', 'comic']),
6
+ openedAt: z.string().datetime(),
7
+ });
8
+ const recentStoreSchema = z.object({
9
+ recent: z.array(recentEntrySchema),
10
+ });
11
+ export function createEmptyRecentStore() {
12
+ return { recent: [] };
13
+ }
14
+ export function parseRecentStore(data) {
15
+ return recentStoreSchema.parse(data);
16
+ }
@@ -0,0 +1,54 @@
1
+ import { NotFoundError } from '../../library/errors.js';
2
+ import { MAX_RECENT } from './schema.js';
3
+ import { ensureRecentStore, readRecentStoreFromDisk, writeRecentStore } from './storage.js';
4
+ export class RecentService {
5
+ libraryService;
6
+ constructor(libraryService) {
7
+ this.libraryService = libraryService;
8
+ }
9
+ static async create(libraryService) {
10
+ await ensureRecentStore();
11
+ return new RecentService(libraryService);
12
+ }
13
+ async recordOpen(itemId) {
14
+ const listItem = this.libraryService.getListItem(itemId);
15
+ if (!listItem) {
16
+ throw new NotFoundError('Item not found');
17
+ }
18
+ const store = await readRecentStoreFromDisk();
19
+ const filtered = store.recent.filter((entry) => entry.itemId !== itemId);
20
+ const updated = {
21
+ recent: [
22
+ {
23
+ itemId,
24
+ type: listItem.type,
25
+ openedAt: new Date().toISOString(),
26
+ },
27
+ ...filtered,
28
+ ].slice(0, MAX_RECENT),
29
+ };
30
+ await writeRecentStore(updated);
31
+ }
32
+ async getRecentItems(progressMap = {}) {
33
+ const store = await readRecentStoreFromDisk();
34
+ const items = [];
35
+ const keptEntries = [];
36
+ let hasStale = false;
37
+ for (const entry of store.recent) {
38
+ const listItem = this.libraryService.getListItem(entry.itemId, progressMap);
39
+ if (!listItem) {
40
+ hasStale = true;
41
+ continue;
42
+ }
43
+ keptEntries.push(entry);
44
+ items.push({
45
+ ...listItem,
46
+ openedAt: entry.openedAt,
47
+ });
48
+ }
49
+ if (hasStale) {
50
+ await writeRecentStore({ recent: keptEntries });
51
+ }
52
+ return items;
53
+ }
54
+ }