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,84 @@
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 { MAX_RECENT } from './schema.js';
8
+ import { RecentService } from './service.js';
9
+ import { readRecentStoreFromDisk } from './storage.js';
10
+ describe('RecentService', () => {
11
+ let tempDir;
12
+ let previousConfigDir;
13
+ let novelRoot;
14
+ let libraryService;
15
+ let recentService;
16
+ let novelId;
17
+ beforeEach(async () => {
18
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
19
+ tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-recent-service-'));
20
+ process.env.CYREADER_CONFIG_DIR = tempDir;
21
+ novelRoot = path.join(tempDir, 'novels');
22
+ await mkdir(novelRoot);
23
+ await Promise.all(Array.from({ length: MAX_RECENT + 2 }, (_, index) => writeFile(path.join(novelRoot, `book-${index}.txt`), 'content')));
24
+ libraryService = new LibraryService(() => ({
25
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
26
+ comicDirectories: [],
27
+ gbkNovelHandling: 'ignore',
28
+ }));
29
+ await libraryService.scan();
30
+ novelId = libraryService.getState().items[0].id;
31
+ recentService = await RecentService.create(libraryService);
32
+ });
33
+ afterEach(async () => {
34
+ if (previousConfigDir === undefined) {
35
+ delete process.env.CYREADER_CONFIG_DIR;
36
+ }
37
+ else {
38
+ process.env.CYREADER_CONFIG_DIR = previousConfigDir;
39
+ }
40
+ await rm(tempDir, { recursive: true, force: true });
41
+ });
42
+ it('records open and moves existing item to front', async () => {
43
+ const secondId = libraryService.getState().items[1].id;
44
+ await recentService.recordOpen(novelId);
45
+ await recentService.recordOpen(secondId);
46
+ await recentService.recordOpen(novelId);
47
+ const items = await recentService.getRecentItems();
48
+ expect(items.map((item) => item.id)).toEqual([novelId, secondId]);
49
+ const store = await readRecentStoreFromDisk();
50
+ expect(store.recent[0]?.itemId).toBe(novelId);
51
+ expect(store.recent[0]?.openedAt).toBe(items[0]?.openedAt);
52
+ });
53
+ it('truncates to MAX_RECENT entries', async () => {
54
+ const ids = libraryService.getState().items.map((item) => item.id);
55
+ await ids.reduce(async (previous, id) => {
56
+ await previous;
57
+ await recentService.recordOpen(id);
58
+ }, Promise.resolve());
59
+ const store = await readRecentStoreFromDisk();
60
+ expect(store.recent).toHaveLength(MAX_RECENT);
61
+ const items = await recentService.getRecentItems();
62
+ expect(items).toHaveLength(MAX_RECENT);
63
+ });
64
+ it('removes stale entries when getting recent items', async () => {
65
+ await recentService.recordOpen(novelId);
66
+ const missingId = 'missing-item-id';
67
+ const store = await readRecentStoreFromDisk();
68
+ const { writeRecentStore } = await import('./storage.js');
69
+ await writeRecentStore({
70
+ recent: [
71
+ { itemId: missingId, type: 'novel', openedAt: '2026-06-12T09:00:00.000Z' },
72
+ ...store.recent,
73
+ ],
74
+ });
75
+ const items = await recentService.getRecentItems();
76
+ expect(items.every((item) => item.id !== missingId)).toBe(true);
77
+ expect(items.some((item) => item.id === novelId)).toBe(true);
78
+ const cleaned = await readRecentStoreFromDisk();
79
+ expect(cleaned.recent.every((entry) => entry.itemId !== missingId)).toBe(true);
80
+ });
81
+ it('throws when recording unknown item', async () => {
82
+ await expect(recentService.recordOpen('missing')).rejects.toBeInstanceOf(NotFoundError);
83
+ });
84
+ });
@@ -0,0 +1,42 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { getConfigDir, getRecentFilePath } from '../../config/paths.js';
3
+ import { createEmptyRecentStore, parseRecentStore } 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 writeRecentStore(store) {
14
+ const configDir = getConfigDir();
15
+ const recentFile = getRecentFilePath();
16
+ const normalized = parseRecentStore(store);
17
+ await mkdir(configDir, { recursive: true });
18
+ await writeFile(recentFile, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
19
+ }
20
+ export async function readRecentStoreFromDisk() {
21
+ const recentFile = getRecentFilePath();
22
+ const content = await readFile(recentFile, 'utf-8');
23
+ let data;
24
+ try {
25
+ data = JSON.parse(content);
26
+ }
27
+ catch (error) {
28
+ throw new Error(`Failed to parse recent file: ${recentFile}`, { cause: error });
29
+ }
30
+ return parseRecentStore(data);
31
+ }
32
+ export async function ensureRecentStore() {
33
+ const configDir = getConfigDir();
34
+ const recentFile = getRecentFilePath();
35
+ await mkdir(configDir, { recursive: true });
36
+ if (!(await fileExists(recentFile))) {
37
+ const empty = createEmptyRecentStore();
38
+ await writeRecentStore(empty);
39
+ return empty;
40
+ }
41
+ return readRecentStoreFromDisk();
42
+ }
@@ -0,0 +1,46 @@
1
+ import { access, mkdtemp, readFile, rm } 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 { getRecentFilePath } from '../../config/paths.js';
6
+ import { ensureRecentStore, readRecentStoreFromDisk, writeRecentStore } from './storage.js';
7
+ describe('recent storage', () => {
8
+ let tempDir;
9
+ let previousConfigDir;
10
+ beforeEach(async () => {
11
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
12
+ tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-recent-'));
13
+ process.env.CYREADER_CONFIG_DIR = tempDir;
14
+ });
15
+ afterEach(async () => {
16
+ if (previousConfigDir === undefined) {
17
+ delete process.env.CYREADER_CONFIG_DIR;
18
+ }
19
+ else {
20
+ process.env.CYREADER_CONFIG_DIR = previousConfigDir;
21
+ }
22
+ await rm(tempDir, { recursive: true, force: true });
23
+ });
24
+ it('creates recent file when missing', async () => {
25
+ const store = await ensureRecentStore();
26
+ const recentFile = getRecentFilePath();
27
+ expect(store).toEqual({ recent: [] });
28
+ await expect(access(recentFile)).resolves.toBeUndefined();
29
+ const content = await readFile(recentFile, 'utf-8');
30
+ expect(JSON.parse(content)).toEqual({ recent: [] });
31
+ });
32
+ it('reads and writes recent store', async () => {
33
+ await ensureRecentStore();
34
+ const payload = {
35
+ recent: [
36
+ {
37
+ itemId: 'abc123',
38
+ type: 'novel',
39
+ openedAt: '2026-06-12T10:00:00.000Z',
40
+ },
41
+ ],
42
+ };
43
+ await writeRecentStore(payload);
44
+ await expect(readRecentStoreFromDisk()).resolves.toEqual(payload);
45
+ });
46
+ });
@@ -0,0 +1,52 @@
1
+ import { Hono } from 'hono';
2
+ import { ZodError } from 'zod';
3
+ import { formatZodError } from '../config/schema.js';
4
+ export function createConfigRoutes(configService, libraryService, options) {
5
+ const routes = new Hono();
6
+ routes.get('/', (c) => {
7
+ return c.json(configService.get());
8
+ });
9
+ routes.put('/', async (c) => {
10
+ let body;
11
+ try {
12
+ body = await c.req.json();
13
+ }
14
+ catch {
15
+ return c.json({ error: 'Invalid JSON body' }, 400);
16
+ }
17
+ try {
18
+ const config = await configService.replace(body);
19
+ options?.onConfigChange?.();
20
+ libraryService?.tryScan();
21
+ return c.json(config);
22
+ }
23
+ catch (error) {
24
+ if (error instanceof ZodError) {
25
+ return c.json(formatZodError(error), 400);
26
+ }
27
+ throw error;
28
+ }
29
+ });
30
+ routes.patch('/', async (c) => {
31
+ let body;
32
+ try {
33
+ body = await c.req.json();
34
+ }
35
+ catch {
36
+ return c.json({ error: 'Invalid JSON body' }, 400);
37
+ }
38
+ try {
39
+ const config = await configService.patch(body);
40
+ options?.onConfigChange?.();
41
+ libraryService?.tryScan();
42
+ return c.json(config);
43
+ }
44
+ catch (error) {
45
+ if (error instanceof ZodError) {
46
+ return c.json(formatZodError(error), 400);
47
+ }
48
+ throw error;
49
+ }
50
+ });
51
+ return routes;
52
+ }
@@ -0,0 +1,147 @@
1
+ import { access, mkdtemp, rm } 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 { createApp } from '../app.js';
6
+ import { ConfigService } from '../config/service.js';
7
+ import { LibraryService } from '../library/service.js';
8
+ import { ProgressService } from '../reading/progress/service.js';
9
+ import { RecentService } from '../reading/recent/service.js';
10
+ function directory(id, directoryPath) {
11
+ return { id, path: directoryPath };
12
+ }
13
+ describe('config routes', () => {
14
+ let tempDir;
15
+ let previousConfigDir;
16
+ let configService;
17
+ let libraryService;
18
+ let app;
19
+ beforeEach(async () => {
20
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
21
+ tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-routes-'));
22
+ process.env.CYREADER_CONFIG_DIR = tempDir;
23
+ configService = await ConfigService.create();
24
+ libraryService = new LibraryService(() => configService.get());
25
+ const recentService = await RecentService.create(libraryService);
26
+ const progressService = await ProgressService.create(libraryService);
27
+ app = createApp(configService, libraryService, recentService, progressService);
28
+ });
29
+ afterEach(async () => {
30
+ if (previousConfigDir === undefined) {
31
+ delete process.env.CYREADER_CONFIG_DIR;
32
+ }
33
+ else {
34
+ process.env.CYREADER_CONFIG_DIR = previousConfigDir;
35
+ }
36
+ await rm(tempDir, { recursive: true, force: true });
37
+ });
38
+ it('GET /api/config returns current config', async () => {
39
+ const res = await app.request('/api/config');
40
+ expect(res.status).toBe(200);
41
+ expect(await res.json()).toEqual({
42
+ novelDirectories: [],
43
+ comicDirectories: [],
44
+ gbkNovelHandling: 'ignore',
45
+ });
46
+ });
47
+ it('PUT /api/config replaces and persists config', async () => {
48
+ const novelDir = path.join(tempDir, 'novels');
49
+ const comicDir = path.join(tempDir, 'comics');
50
+ const body = {
51
+ novelDirectories: [directory('novel-1', novelDir)],
52
+ comicDirectories: [directory('comic-1', comicDir)],
53
+ };
54
+ const res = await app.request('/api/config', {
55
+ method: 'PUT',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify(body),
58
+ });
59
+ expect(res.status).toBe(200);
60
+ const expected = { ...body, gbkNovelHandling: 'ignore' };
61
+ expect(await res.json()).toEqual(expected);
62
+ expect(configService.get()).toEqual(expected);
63
+ const getRes = await app.request('/api/config');
64
+ expect(await getRes.json()).toEqual(expected);
65
+ await expect(access(novelDir)).resolves.toBeUndefined();
66
+ await expect(access(comicDir)).resolves.toBeUndefined();
67
+ });
68
+ it('PATCH /api/config updates only provided fields', async () => {
69
+ const novelDir = path.join(tempDir, 'novels');
70
+ await app.request('/api/config', {
71
+ method: 'PUT',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({
74
+ novelDirectories: [directory('novel-1', novelDir)],
75
+ comicDirectories: [],
76
+ }),
77
+ });
78
+ const comicDir = path.join(tempDir, 'comics');
79
+ const res = await app.request('/api/config', {
80
+ method: 'PATCH',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({
83
+ comicDirectories: [directory('comic-1', comicDir)],
84
+ }),
85
+ });
86
+ expect(res.status).toBe(200);
87
+ expect(await res.json()).toEqual({
88
+ novelDirectories: [directory('novel-1', novelDir)],
89
+ comicDirectories: [directory('comic-1', comicDir)],
90
+ gbkNovelHandling: 'ignore',
91
+ });
92
+ });
93
+ it('PUT /api/config rejects duplicate directory paths with 400', async () => {
94
+ const novelDir = path.join(tempDir, 'novels');
95
+ const res = await app.request('/api/config', {
96
+ method: 'PUT',
97
+ headers: { 'Content-Type': 'application/json' },
98
+ body: JSON.stringify({
99
+ novelDirectories: [directory('novel-1', novelDir), directory('novel-2', novelDir)],
100
+ comicDirectories: [],
101
+ }),
102
+ });
103
+ expect(res.status).toBe(400);
104
+ const body = await res.json();
105
+ expect(body.error).toBe('Validation failed');
106
+ expect(body.details[0]?.message).toBe('Directory path must be unique');
107
+ });
108
+ it('PUT /api/config rejects relative paths with 400', async () => {
109
+ const res = await app.request('/api/config', {
110
+ method: 'PUT',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({
113
+ novelDirectories: [directory('novel-1', 'relative/novels')],
114
+ comicDirectories: [],
115
+ }),
116
+ });
117
+ expect(res.status).toBe(400);
118
+ const body = await res.json();
119
+ expect(body.error).toBe('Validation failed');
120
+ expect(body.details[0]?.message).toContain('absolute');
121
+ });
122
+ it('PATCH /api/config rejects relative paths with 400', async () => {
123
+ const res = await app.request('/api/config', {
124
+ method: 'PATCH',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ body: JSON.stringify({
127
+ novelDirectories: [directory('novel-1', './novels')],
128
+ }),
129
+ });
130
+ expect(res.status).toBe(400);
131
+ const body = await res.json();
132
+ expect(body.error).toBe('Validation failed');
133
+ expect(body.details[0]?.message).toContain('absolute');
134
+ });
135
+ it('PUT /api/config rejects invalid body with 400', async () => {
136
+ const res = await app.request('/api/config', {
137
+ method: 'PUT',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({
140
+ novelDirectories: 'not-an-array',
141
+ comicDirectories: [],
142
+ }),
143
+ });
144
+ expect(res.status).toBe(400);
145
+ expect((await res.json()).error).toBe('Validation failed');
146
+ });
147
+ });
@@ -0,0 +1,31 @@
1
+ import { Hono } from 'hono';
2
+ import { NotFoundError } from '../library/errors.js';
3
+ export function createLibraryRoutes(libraryService, progressService) {
4
+ const routes = new Hono();
5
+ routes.get('/', async (c) => {
6
+ const progressMap = await progressService.getProgressMap();
7
+ return c.json(libraryService.getState(progressMap));
8
+ });
9
+ routes.get('/:id', async (c) => {
10
+ const id = c.req.param('id');
11
+ try {
12
+ const progressMap = await progressService.getProgressMap();
13
+ const detail = libraryService.getItemDetail(id, progressMap);
14
+ return c.json(detail);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof NotFoundError) {
18
+ return c.json({ error: error.message }, 404);
19
+ }
20
+ throw error;
21
+ }
22
+ });
23
+ routes.post('/scan', (c) => {
24
+ if (libraryService.isScanning()) {
25
+ return c.json({ error: 'Scan already in progress' }, 409);
26
+ }
27
+ libraryService.tryScan();
28
+ return c.body(null, 202);
29
+ });
30
+ return routes;
31
+ }
@@ -0,0 +1,125 @@
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, vi } from 'vitest';
5
+ import { createApp } from '../app.js';
6
+ import { ConfigService } from '../config/service.js';
7
+ import { LibraryService } from '../library/service.js';
8
+ import { writeTestImage } from '../library/test-images.js';
9
+ import { ProgressService } from '../reading/progress/service.js';
10
+ import { RecentService } from '../reading/recent/service.js';
11
+ describe('library routes', () => {
12
+ let tempDir;
13
+ let previousConfigDir;
14
+ let configService;
15
+ let libraryService;
16
+ let app;
17
+ let novelRoot;
18
+ let comicRoot;
19
+ let mangaDir;
20
+ beforeEach(async () => {
21
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
22
+ tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-library-routes-'));
23
+ process.env.CYREADER_CONFIG_DIR = tempDir;
24
+ novelRoot = path.join(tempDir, 'novels');
25
+ comicRoot = path.join(tempDir, 'comics');
26
+ mangaDir = path.join(comicRoot, 'manga');
27
+ await mkdir(novelRoot);
28
+ await mkdir(mangaDir, { recursive: true });
29
+ await writeFile(path.join(novelRoot, 'book.txt'), 'chapter one\n\nchapter two');
30
+ await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 400);
31
+ await writeTestImage(path.join(mangaDir, '2.jpg'), 300, 400);
32
+ configService = await ConfigService.create();
33
+ await configService.replace({
34
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
35
+ comicDirectories: [{ id: 'comic-root', path: comicRoot }],
36
+ });
37
+ libraryService = new LibraryService(() => configService.get());
38
+ const recentService = await RecentService.create(libraryService);
39
+ const progressService = await ProgressService.create(libraryService);
40
+ app = createApp(configService, libraryService, recentService, progressService);
41
+ await libraryService.scan();
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('GET /api/library returns scanning state and list items', async () => {
53
+ const res = await app.request('/api/library');
54
+ expect(res.status).toBe(200);
55
+ const body = await res.json();
56
+ expect(body.scanning).toBe(false);
57
+ expect(body.items).toEqual(expect.arrayContaining([
58
+ {
59
+ id: expect.any(String),
60
+ type: 'novel',
61
+ title: 'book',
62
+ progress: null,
63
+ },
64
+ {
65
+ id: expect.any(String),
66
+ type: 'comic',
67
+ title: 'manga',
68
+ cover: expect.stringMatching(/^\/static\/comic-root\/manga\/cover\.jpg$/),
69
+ pageCount: 2,
70
+ progress: null,
71
+ },
72
+ ]));
73
+ expect(body.items).not.toEqual(expect.arrayContaining([expect.objectContaining({ path: expect.any(String) })]));
74
+ });
75
+ it('GET /api/library/:id returns novel detail', async () => {
76
+ const novel = libraryService.getState().items.find((item) => item.type === 'novel');
77
+ const res = await app.request(`/api/library/${novel.id}`);
78
+ expect(res.status).toBe(200);
79
+ expect(await res.json()).toEqual({
80
+ id: novel.id,
81
+ type: 'novel',
82
+ title: 'book',
83
+ directoryId: 'novel-root',
84
+ filename: 'book.txt',
85
+ progress: null,
86
+ });
87
+ });
88
+ it('serves novel content via static URL', async () => {
89
+ const novel = libraryService.getState().items.find((item) => item.type === 'novel');
90
+ const detailRes = await app.request(`/api/library/${novel.id}`);
91
+ const detail = (await detailRes.json());
92
+ const contentRes = await app.request(`/static/${detail.directoryId}/${detail.filename}`);
93
+ expect(contentRes.status).toBe(200);
94
+ expect(await contentRes.text()).toBe('chapter one\n\nchapter two');
95
+ expect(contentRes.headers.get('content-type')).toBe('text/plain; charset=utf-8');
96
+ });
97
+ it('GET /api/library/:id returns comic detail', async () => {
98
+ const comic = libraryService.getState().items.find((item) => item.type === 'comic');
99
+ const res = await app.request(`/api/library/${comic.id}`);
100
+ expect(res.status).toBe(200);
101
+ expect(await res.json()).toEqual({
102
+ id: comic.id,
103
+ type: 'comic',
104
+ title: 'manga',
105
+ directoryId: 'comic-root',
106
+ pages: ['1.jpg', '2.jpg'],
107
+ progress: null,
108
+ });
109
+ });
110
+ it('GET /api/library/:id returns 404 for unknown item', async () => {
111
+ const res = await app.request('/api/library/unknown');
112
+ expect(res.status).toBe(404);
113
+ expect(await res.json()).toEqual({ error: 'Item not found' });
114
+ });
115
+ it('POST /api/library/scan returns 202 and starts scan', async () => {
116
+ const res = await app.request('/api/library/scan', { method: 'POST' });
117
+ expect(res.status).toBe(202);
118
+ });
119
+ it('POST /api/library/scan returns 409 when scan is in progress', async () => {
120
+ vi.spyOn(libraryService, 'isScanning').mockReturnValue(true);
121
+ const res = await app.request('/api/library/scan', { method: 'POST' });
122
+ expect(res.status).toBe(409);
123
+ expect(await res.json()).toEqual({ error: 'Scan already in progress' });
124
+ });
125
+ });
@@ -0,0 +1,66 @@
1
+ import { Hono } from 'hono';
2
+ import { z } from 'zod';
3
+ import { NotFoundError } from '../library/errors.js';
4
+ import { progressInputSchema } from '../reading/progress/schema.js';
5
+ const recordRecentBodySchema = z.object({
6
+ itemId: z.string().min(1),
7
+ });
8
+ export function createReadingRoutes(recentService, progressService) {
9
+ const routes = new Hono();
10
+ routes.get('/recent', async (c) => {
11
+ const progressMap = await progressService.getProgressMap();
12
+ const items = await recentService.getRecentItems(progressMap);
13
+ return c.json({ items });
14
+ });
15
+ routes.post('/recent', async (c) => {
16
+ let body;
17
+ try {
18
+ body = await c.req.json();
19
+ }
20
+ catch {
21
+ return c.json({ error: 'Invalid JSON body' }, 400);
22
+ }
23
+ const parsed = recordRecentBodySchema.safeParse(body);
24
+ if (!parsed.success) {
25
+ return c.json({ error: 'Validation failed' }, 400);
26
+ }
27
+ try {
28
+ await recentService.recordOpen(parsed.data.itemId);
29
+ return c.body(null, 204);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof NotFoundError) {
33
+ return c.json({ error: error.message }, 404);
34
+ }
35
+ throw error;
36
+ }
37
+ });
38
+ routes.put('/progress/:itemId', async (c) => {
39
+ const itemId = c.req.param('itemId');
40
+ let body;
41
+ try {
42
+ body = await c.req.json();
43
+ }
44
+ catch {
45
+ return c.json({ error: 'Invalid JSON body' }, 400);
46
+ }
47
+ const parsed = progressInputSchema.safeParse(body);
48
+ if (!parsed.success) {
49
+ return c.json({ error: 'Validation failed' }, 400);
50
+ }
51
+ try {
52
+ const entry = await progressService.saveProgress(itemId, parsed.data);
53
+ return c.json(entry);
54
+ }
55
+ catch (error) {
56
+ if (error instanceof NotFoundError) {
57
+ return c.json({ error: error.message }, 404);
58
+ }
59
+ if (error instanceof TypeError) {
60
+ return c.json({ error: error.message }, 400);
61
+ }
62
+ throw error;
63
+ }
64
+ });
65
+ return routes;
66
+ }