cyreader 0.1.1 → 0.1.2

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 (58) hide show
  1. package/bundle/server/dist/config/paths.js +3 -0
  2. package/bundle/server/dist/library/cache.js +70 -0
  3. package/bundle/server/dist/library/scan.js +54 -8
  4. package/bundle/server/dist/library/scan.test.js +99 -18
  5. package/bundle/server/dist/library/service.js +5 -1
  6. package/bundle/server/dist/library/service.test.js +16 -1
  7. package/bundle/server/dist/reading/progress/merge.js +5 -4
  8. package/bundle/server/dist/reading/progress/schema.js +39 -3
  9. package/bundle/server/dist/reading/progress/schema.test.js +36 -3
  10. package/bundle/server/dist/reading/progress/service.js +2 -3
  11. package/bundle/server/dist/reading/progress/service.test.js +10 -9
  12. package/bundle/server/dist/routes/library.test.js +8 -1
  13. package/bundle/web/dist/assets/_shell-Bm2pxAQP.js +5 -0
  14. package/bundle/web/dist/assets/_shell-Cij8gMSv.js +1 -0
  15. package/bundle/web/dist/assets/comic._id-Djtr8diE.js +1 -0
  16. package/bundle/web/dist/assets/dist-DzLEGVNH.js +1 -0
  17. package/bundle/web/dist/assets/dist-QfKKfHSL.js +1 -0
  18. package/bundle/web/dist/assets/index-BHhqsr5H.js +10 -0
  19. package/bundle/web/dist/assets/index-D9hL3zrL.css +2 -0
  20. package/bundle/web/dist/assets/library-D_1sil_O.js +1 -0
  21. package/bundle/web/dist/assets/library-utils-BRrejTkM.js +1 -0
  22. package/bundle/web/dist/assets/mutation-ChAIwNv3.js +1 -0
  23. package/bundle/web/dist/assets/novel._id-C62VYvcl.js +4 -0
  24. package/bundle/web/dist/assets/query-keys-CbbgiZx4.js +1 -0
  25. package/bundle/web/dist/assets/reading-BozmJNi1.js +1 -0
  26. package/bundle/web/dist/assets/reading-progress-C5bkK-vF.js +1 -0
  27. package/bundle/web/dist/assets/save-reading-progress-D231ROaQ.js +1 -0
  28. package/bundle/web/dist/assets/scroll-area-CyEO8R_G.js +1 -0
  29. package/bundle/web/dist/assets/{settings-2-CCv0KiQI.js → settings-2-8mnAuSax.js} +1 -1
  30. package/bundle/web/dist/assets/settings-BRi0XyRg.js +1 -0
  31. package/bundle/web/dist/assets/shell-context-Buw4m44t.js +41 -0
  32. package/bundle/web/dist/assets/use-library-Bh7Bunn1.js +1 -0
  33. package/bundle/web/dist/assets/{useNavigate-BMyovDLu.js → useNavigate-B29ssGbr.js} +1 -1
  34. package/bundle/web/dist/assets/useRouterState-4dM5FKiy.js +1 -0
  35. package/bundle/web/dist/assets/utils-ChkMmPzE.js +1 -0
  36. package/bundle/web/dist/index.html +5 -5
  37. package/package.json +2 -2
  38. package/bundle/server/dist/app.test.js +0 -9
  39. package/bundle/web/dist/assets/_shell-Co1KI-wo.js +0 -5
  40. package/bundle/web/dist/assets/_shell-bNqNRnHj.js +0 -1
  41. package/bundle/web/dist/assets/comic._id-CC6qdNbU.js +0 -1
  42. package/bundle/web/dist/assets/dist-CqonX7c8.js +0 -1
  43. package/bundle/web/dist/assets/image-BZKAkGUy.js +0 -1
  44. package/bundle/web/dist/assets/index-BQZoypuf.css +0 -2
  45. package/bundle/web/dist/assets/index-Pdit8x1d.js +0 -10
  46. package/bundle/web/dist/assets/input-tgPw9P7N.js +0 -41
  47. package/bundle/web/dist/assets/library-g-QZ29Jj.js +0 -1
  48. package/bundle/web/dist/assets/mutation-CwzbY9ri.js +0 -1
  49. package/bundle/web/dist/assets/novel._id-ByrQLxAT.js +0 -4
  50. package/bundle/web/dist/assets/query-keys-CDsPIOEO.js +0 -1
  51. package/bundle/web/dist/assets/reading-BseNE7P3.js +0 -1
  52. package/bundle/web/dist/assets/reading-progress-BcmbUd_d.js +0 -1
  53. package/bundle/web/dist/assets/save-reading-progress-yvAw7ZNh.js +0 -1
  54. package/bundle/web/dist/assets/scroll-area-awhNOWo3.js +0 -1
  55. package/bundle/web/dist/assets/settings-DmjzIwkt.js +0 -1
  56. package/bundle/web/dist/assets/shell-context-BT0BT8PF.js +0 -1
  57. package/bundle/web/dist/assets/toggle-group-CLpSuWfq.js +0 -1
  58. package/bundle/web/dist/assets/utils-BmM72imW.js +0 -1
@@ -16,3 +16,6 @@ export function getRecentFilePath() {
16
16
  export function getProgressFilePath() {
17
17
  return path.join(getConfigDir(), 'progress.json');
18
18
  }
19
+ export function getLibraryCacheFilePath() {
20
+ return path.join(getConfigDir(), 'library-cache.json');
21
+ }
@@ -0,0 +1,70 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { z } from 'zod';
3
+ import { getConfigDir, getLibraryCacheFilePath } from '../config/paths.js';
4
+ const comicPageSchema = z.object({
5
+ filename: z.string().min(1),
6
+ width: z.number().int().positive(),
7
+ height: z.number().int().positive(),
8
+ });
9
+ const novelCacheItemSchema = z.object({
10
+ id: z.string().min(1),
11
+ type: z.literal('novel'),
12
+ title: z.string(),
13
+ path: z.string().min(1),
14
+ addedAt: z.string().datetime(),
15
+ directoryId: z.string().min(1),
16
+ filename: z.string().min(1),
17
+ });
18
+ const comicCacheItemSchema = z.object({
19
+ id: z.string().min(1),
20
+ type: z.literal('comic'),
21
+ title: z.string(),
22
+ path: z.string().min(1),
23
+ addedAt: z.string().datetime(),
24
+ directoryId: z.string().min(1),
25
+ pages: z.array(comicPageSchema),
26
+ coverFilename: z.string().nullable(),
27
+ });
28
+ const libraryCacheSchema = z.object({
29
+ items: z.array(z.discriminatedUnion('type', [novelCacheItemSchema, comicCacheItemSchema])),
30
+ });
31
+ async function fileExists(filePath) {
32
+ try {
33
+ await access(filePath);
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ export function createEmptyLibraryScanCache() {
41
+ return { items: [] };
42
+ }
43
+ export function parseLibraryScanCache(data) {
44
+ const parsed = libraryCacheSchema.safeParse(data);
45
+ if (!parsed.success) {
46
+ return createEmptyLibraryScanCache();
47
+ }
48
+ return { items: parsed.data.items };
49
+ }
50
+ export async function readLibraryScanCache() {
51
+ const cacheFile = getLibraryCacheFilePath();
52
+ if (!(await fileExists(cacheFile))) {
53
+ return createEmptyLibraryScanCache();
54
+ }
55
+ let data;
56
+ try {
57
+ data = JSON.parse(await readFile(cacheFile, 'utf-8'));
58
+ }
59
+ catch {
60
+ return createEmptyLibraryScanCache();
61
+ }
62
+ return parseLibraryScanCache(data);
63
+ }
64
+ export async function writeLibraryScanCache(cache) {
65
+ const configDir = getConfigDir();
66
+ const cacheFile = getLibraryCacheFilePath();
67
+ const normalized = parseLibraryScanCache(cache);
68
+ await mkdir(configDir, { recursive: true });
69
+ await writeFile(cacheFile, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
70
+ }
@@ -1,5 +1,7 @@
1
1
  import { readFile, readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import sharp from 'sharp';
4
+ import { readLibraryScanCache, writeLibraryScanCache } from './cache.js';
3
5
  import { generateComicCoverIfMissing, resolveComicCoverFilename } from './cover.js';
4
6
  import { isComicCoverFile, isHiddenName, isImageFile, isTxtFile, sortByNaturalName, } from './files.js';
5
7
  import { makeLocalItemId } from './id.js';
@@ -44,7 +46,7 @@ async function shouldIncludeNovel(childPath, gbkNovelHandling) {
44
46
  return false;
45
47
  }
46
48
  }
47
- async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling) {
49
+ async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling, cacheByPath) {
48
50
  const children = await listDirectChildren(root);
49
51
  const stats = await Promise.all(children.map(async (childPath) => ({
50
52
  childPath,
@@ -56,11 +58,21 @@ async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling) {
56
58
  }
57
59
  return isTxtFile(path.basename(childPath));
58
60
  });
59
- const inclusion = await Promise.all(txtFiles.map(async ({ childPath }) => ({
61
+ const cachedNovels = [];
62
+ const uncachedTxtFiles = [];
63
+ for (const entry of txtFiles) {
64
+ const cached = cacheByPath.get(entry.childPath);
65
+ if (cached?.type === 'novel') {
66
+ cachedNovels.push({ ...cached, directoryId });
67
+ continue;
68
+ }
69
+ uncachedTxtFiles.push(entry);
70
+ }
71
+ const inclusion = await Promise.all(uncachedTxtFiles.map(async ({ childPath }) => ({
60
72
  childPath,
61
73
  include: await shouldIncludeNovel(childPath, gbkNovelHandling),
62
74
  })));
63
- return inclusion
75
+ const scannedNovels = inclusion
64
76
  .filter((entry) => entry.include)
65
77
  .map(({ childPath }) => {
66
78
  const name = path.basename(childPath);
@@ -70,10 +82,12 @@ async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling) {
70
82
  type: 'novel',
71
83
  title,
72
84
  path: childPath,
85
+ addedAt: new Date().toISOString(),
73
86
  directoryId,
74
87
  filename: name,
75
88
  };
76
89
  });
90
+ return [...cachedNovels, ...scannedNovels];
77
91
  }
78
92
  async function listDirectImageFiles(dir) {
79
93
  const children = await listDirectChildren(dir);
@@ -94,7 +108,26 @@ async function listDirectImageFiles(dir) {
94
108
  }
95
109
  return sortByNaturalName(images);
96
110
  }
97
- async function scanComicsInDirectory(directoryId, root) {
111
+ async function resolveComicPage(imagePath) {
112
+ try {
113
+ const metadata = await sharp(imagePath).metadata();
114
+ const width = metadata.width;
115
+ const height = metadata.height;
116
+ if (!width || !height) {
117
+ return null;
118
+ }
119
+ return {
120
+ filename: path.basename(imagePath),
121
+ width,
122
+ height,
123
+ };
124
+ }
125
+ catch (error) {
126
+ console.error(`Failed to read comic page metadata: ${imagePath}`, error);
127
+ return null;
128
+ }
129
+ }
130
+ async function scanComicsInDirectory(directoryId, root, cacheByPath) {
98
131
  const children = await listDirectChildren(root);
99
132
  const stats = await Promise.all(children.map(async (childPath) => ({
100
133
  childPath,
@@ -104,10 +137,18 @@ async function scanComicsInDirectory(directoryId, root) {
104
137
  .filter(({ childStat }) => childStat?.isDirectory())
105
138
  .map(({ childPath }) => childPath);
106
139
  const comics = await Promise.all(comicDirs.map(async (childPath) => {
140
+ const cached = cacheByPath.get(childPath);
141
+ if (cached?.type === 'comic') {
142
+ return Object.assign({}, cached, { directoryId });
143
+ }
107
144
  const images = await listDirectImageFiles(childPath);
108
145
  if (images.length === 0) {
109
146
  return null;
110
147
  }
148
+ const pages = (await Promise.all(images.map((imagePath) => resolveComicPage(imagePath)))).filter((page) => page !== null);
149
+ if (pages.length === 0) {
150
+ return null;
151
+ }
111
152
  await generateComicCoverIfMissing(childPath, images[0]);
112
153
  const coverFilename = await resolveComicCoverFilename(childPath);
113
154
  return {
@@ -115,17 +156,22 @@ async function scanComicsInDirectory(directoryId, root) {
115
156
  type: 'comic',
116
157
  title: path.basename(childPath),
117
158
  path: childPath,
159
+ addedAt: new Date().toISOString(),
118
160
  directoryId,
119
- pages: images.map((imagePath) => path.basename(imagePath)),
161
+ pages,
120
162
  coverFilename,
121
163
  };
122
164
  }));
123
165
  return comics.filter((comic) => comic !== null);
124
166
  }
125
167
  export async function scanLibrary(config) {
126
- const novelResults = await Promise.all(config.novelDirectories.map((directory) => scanNovelsInDirectory(directory.id, directory.path, config.gbkNovelHandling)));
127
- const comicResults = await Promise.all(config.comicDirectories.map((directory) => scanComicsInDirectory(directory.id, directory.path)));
128
- return [...novelResults.flat(), ...comicResults.flat()];
168
+ const cache = await readLibraryScanCache();
169
+ const cacheByPath = new Map(cache.items.map((item) => [item.path, item]));
170
+ const novelResults = await Promise.all(config.novelDirectories.map((directory) => scanNovelsInDirectory(directory.id, directory.path, config.gbkNovelHandling, cacheByPath)));
171
+ const comicResults = await Promise.all(config.comicDirectories.map((directory) => scanComicsInDirectory(directory.id, directory.path, cacheByPath)));
172
+ const items = [...novelResults.flat(), ...comicResults.flat()];
173
+ await writeLibraryScanCache({ items });
174
+ return items;
129
175
  }
130
176
  export async function listComicPages(comicDir) {
131
177
  return listDirectImageFiles(comicDir);
@@ -3,15 +3,25 @@ import { tmpdir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import sharp from 'sharp';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { getLibraryCacheFilePath } from '../config/paths.js';
6
7
  import { COMIC_COVER_FILENAME } from './files.js';
7
8
  import { listComicPages, scanLibrary } from './scan.js';
8
9
  import { writeTestImage } from './test-images.js';
9
10
  describe('scanLibrary', () => {
10
11
  let tempDir;
12
+ let previousConfigDir;
11
13
  beforeEach(async () => {
14
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
12
15
  tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-scan-'));
16
+ process.env.CYREADER_CONFIG_DIR = tempDir;
13
17
  });
14
18
  afterEach(async () => {
19
+ if (previousConfigDir === undefined) {
20
+ delete process.env.CYREADER_CONFIG_DIR;
21
+ }
22
+ else {
23
+ process.env.CYREADER_CONFIG_DIR = previousConfigDir;
24
+ }
15
25
  await rm(tempDir, { recursive: true, force: true });
16
26
  });
17
27
  it('ignores GBK txt files when gbkNovelHandling is undefined', async () => {
@@ -97,10 +107,10 @@ describe('scanLibrary', () => {
97
107
  const mangaDir = path.join(comicRoot, 'manga-a');
98
108
  const nested = path.join(mangaDir, 'chapter');
99
109
  await mkdir(nested, { recursive: true });
100
- await writeFile(path.join(mangaDir, '10.jpg'), '');
101
- await writeFile(path.join(mangaDir, '2.png'), '');
102
- await writeFile(path.join(mangaDir, '1.webp'), '');
103
- await writeFile(path.join(nested, 'ignored.jpeg'), '');
110
+ await writeTestImage(path.join(mangaDir, '10.jpg'), 1000, 1200);
111
+ await writeTestImage(path.join(mangaDir, '2.png'), 200, 300);
112
+ await writeTestImage(path.join(mangaDir, '1.webp'), 100, 150);
113
+ await writeTestImage(path.join(nested, 'ignored.jpeg'), 50, 50);
104
114
  const items = await scanLibrary({
105
115
  novelDirectories: [],
106
116
  comicDirectories: [{ id: 'comic-root', path: comicRoot }],
@@ -111,8 +121,13 @@ describe('scanLibrary', () => {
111
121
  type: 'comic',
112
122
  title: 'manga-a',
113
123
  directoryId: 'comic-root',
114
- pages: ['1.webp', '2.png', '10.jpg'],
115
- coverFilename: null,
124
+ pages: [
125
+ { filename: '1.webp', width: 100, height: 150 },
126
+ { filename: '2.png', width: 200, height: 300 },
127
+ { filename: '10.jpg', width: 1000, height: 1200 },
128
+ ],
129
+ coverFilename: COMIC_COVER_FILENAME,
130
+ addedAt: expect.any(String),
116
131
  });
117
132
  });
118
133
  it('ignores root-level images and folders without direct images', async () => {
@@ -149,7 +164,10 @@ describe('scanLibrary', () => {
149
164
  expect(items[0]).toMatchObject({
150
165
  type: 'comic',
151
166
  title: 'manga-cover',
152
- pages: ['1.jpg', '2.jpg'],
167
+ pages: [
168
+ { filename: '1.jpg', width: 300, height: 399 },
169
+ { filename: '2.jpg', width: 301, height: 400 },
170
+ ],
153
171
  coverFilename: COMIC_COVER_FILENAME,
154
172
  });
155
173
  });
@@ -182,11 +200,14 @@ describe('scanLibrary', () => {
182
200
  });
183
201
  expect(items[0]).toMatchObject({
184
202
  type: 'comic',
185
- pages: ['1.jpg', '2.jpg'],
203
+ pages: [
204
+ { filename: '1.jpg', width: 300, height: 300 },
205
+ { filename: '2.jpg', width: 300, height: 300 },
206
+ ],
186
207
  coverFilename: COMIC_COVER_FILENAME,
187
208
  });
188
209
  });
189
- it('keeps scanning comics when cover generation fails', async () => {
210
+ it('skips comic images when metadata cannot be read', async () => {
190
211
  const comicRoot = path.join(tempDir, 'comics');
191
212
  const mangaDir = path.join(comicRoot, 'manga-invalid');
192
213
  await mkdir(mangaDir, { recursive: true });
@@ -196,20 +217,14 @@ describe('scanLibrary', () => {
196
217
  comicDirectories: [{ id: 'comic-root', path: comicRoot }],
197
218
  gbkNovelHandling: 'ignore',
198
219
  });
199
- expect(items).toHaveLength(1);
200
- expect(items[0]).toMatchObject({
201
- type: 'comic',
202
- title: 'manga-invalid',
203
- pages: ['1.jpg'],
204
- coverFilename: null,
205
- });
220
+ expect(items).toHaveLength(0);
206
221
  await expect(access(path.join(mangaDir, COMIC_COVER_FILENAME))).rejects.toThrow();
207
222
  });
208
223
  it('supports jpeg extension for comic cover', async () => {
209
224
  const comicRoot = path.join(tempDir, 'comics');
210
225
  const mangaDir = path.join(comicRoot, 'manga-b');
211
226
  await mkdir(mangaDir, { recursive: true });
212
- await writeFile(path.join(mangaDir, 'cover.jpeg'), '');
227
+ await writeTestImage(path.join(mangaDir, 'cover.jpeg'), 320, 480);
213
228
  const items = await scanLibrary({
214
229
  novelDirectories: [],
215
230
  comicDirectories: [{ id: 'comic-root', path: comicRoot }],
@@ -219,8 +234,74 @@ describe('scanLibrary', () => {
219
234
  expect(items[0]).toMatchObject({
220
235
  type: 'comic',
221
236
  directoryId: 'comic-root',
222
- pages: ['cover.jpeg'],
237
+ pages: [{ filename: 'cover.jpeg', width: 320, height: 480 }],
238
+ });
239
+ });
240
+ it('writes cache with comic page dimensions and reuses cached comic directories', async () => {
241
+ const comicRoot = path.join(tempDir, 'comics');
242
+ const mangaDir = path.join(comicRoot, 'manga-cached');
243
+ await mkdir(mangaDir, { recursive: true });
244
+ await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 450);
245
+ const first = await scanLibrary({
246
+ novelDirectories: [],
247
+ comicDirectories: [{ id: 'comic-root', path: comicRoot }],
248
+ gbkNovelHandling: 'ignore',
249
+ });
250
+ expect(first[0]).toMatchObject({
251
+ type: 'comic',
252
+ pages: [{ filename: '1.jpg', width: 300, height: 450 }],
253
+ coverFilename: COMIC_COVER_FILENAME,
254
+ addedAt: expect.any(String),
255
+ });
256
+ await rm(path.join(mangaDir, '1.jpg'));
257
+ await rm(path.join(mangaDir, COMIC_COVER_FILENAME));
258
+ const second = await scanLibrary({
259
+ novelDirectories: [],
260
+ comicDirectories: [{ id: 'comic-root', path: comicRoot }],
261
+ gbkNovelHandling: 'ignore',
223
262
  });
263
+ expect(second).toEqual(first);
264
+ await expect(access(path.join(mangaDir, COMIC_COVER_FILENAME))).rejects.toThrow();
265
+ const cache = JSON.parse(await readFile(getLibraryCacheFilePath(), 'utf-8'));
266
+ expect(cache.items).toHaveLength(1);
267
+ });
268
+ it('reuses cached novels without rechecking encoding', async () => {
269
+ const novelRoot = path.join(tempDir, 'novels');
270
+ const filePath = path.join(novelRoot, 'book.txt');
271
+ await mkdir(novelRoot);
272
+ await writeFile(filePath, 'content');
273
+ const first = await scanLibrary({
274
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
275
+ comicDirectories: [],
276
+ gbkNovelHandling: 'ignore',
277
+ });
278
+ await writeFile(filePath, Buffer.from([0xd6, 0xd0, 0xce, 0xc4]));
279
+ const second = await scanLibrary({
280
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
281
+ comicDirectories: [],
282
+ gbkNovelHandling: 'ignore',
283
+ });
284
+ expect(second).toEqual(first);
285
+ });
286
+ it('removes deleted entries from cache after scanning', async () => {
287
+ const novelRoot = path.join(tempDir, 'novels');
288
+ const filePath = path.join(novelRoot, 'book.txt');
289
+ await mkdir(novelRoot);
290
+ await writeFile(filePath, 'content');
291
+ await scanLibrary({
292
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
293
+ comicDirectories: [],
294
+ gbkNovelHandling: 'ignore',
295
+ });
296
+ await rm(filePath);
297
+ const items = await scanLibrary({
298
+ novelDirectories: [{ id: 'novel-root', path: novelRoot }],
299
+ comicDirectories: [],
300
+ gbkNovelHandling: 'ignore',
301
+ });
302
+ const cache = JSON.parse(await readFile(getLibraryCacheFilePath(), 'utf-8'));
303
+ expect(items).toHaveLength(0);
304
+ expect(cache.items).toHaveLength(0);
224
305
  });
225
306
  });
226
307
  describe('listComicPages', () => {
@@ -8,13 +8,15 @@ export function toListItemBase(item) {
8
8
  id: item.id,
9
9
  type: 'novel',
10
10
  title: item.title,
11
+ addedAt: item.addedAt,
11
12
  };
12
13
  }
13
- const coverPage = item.coverFilename ?? item.pages[0];
14
+ const coverPage = item.coverFilename ?? item.pages[0]?.filename;
14
15
  return {
15
16
  id: item.id,
16
17
  type: 'comic',
17
18
  title: item.title,
19
+ addedAt: item.addedAt,
18
20
  cover: coverPage ? buildComicImageUrl(item.directoryId, item.title, coverPage) : '',
19
21
  pageCount: item.pages.length,
20
22
  };
@@ -25,6 +27,7 @@ function toItemDetailBase(item) {
25
27
  id: item.id,
26
28
  type: 'novel',
27
29
  title: item.title,
30
+ addedAt: item.addedAt,
28
31
  directoryId: item.directoryId,
29
32
  filename: item.filename,
30
33
  };
@@ -33,6 +36,7 @@ function toItemDetailBase(item) {
33
36
  id: item.id,
34
37
  type: 'comic',
35
38
  title: item.title,
39
+ addedAt: item.addedAt,
36
40
  directoryId: item.directoryId,
37
41
  pages: item.pages,
38
42
  };
@@ -7,10 +7,13 @@ import { LibraryService } from './service.js';
7
7
  import { writeTestImage } from './test-images.js';
8
8
  describe('LibraryService', () => {
9
9
  let tempDir;
10
+ let previousConfigDir;
10
11
  let novelRoot;
11
12
  let comicRoot;
12
13
  beforeEach(async () => {
14
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
13
15
  tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-service-'));
16
+ process.env.CYREADER_CONFIG_DIR = tempDir;
14
17
  novelRoot = path.join(tempDir, 'novels');
15
18
  comicRoot = path.join(tempDir, 'comics');
16
19
  await mkdir(novelRoot);
@@ -18,6 +21,12 @@ describe('LibraryService', () => {
18
21
  await writeFile(path.join(novelRoot, 'book.txt'), 'content');
19
22
  });
20
23
  afterEach(async () => {
24
+ if (previousConfigDir === undefined) {
25
+ delete process.env.CYREADER_CONFIG_DIR;
26
+ }
27
+ else {
28
+ process.env.CYREADER_CONFIG_DIR = previousConfigDir;
29
+ }
21
30
  await rm(tempDir, { recursive: true, force: true });
22
31
  });
23
32
  function createService() {
@@ -37,6 +46,7 @@ describe('LibraryService', () => {
37
46
  id: expect.any(String),
38
47
  type: 'novel',
39
48
  title: 'book',
49
+ addedAt: expect.any(String),
40
50
  progress: null,
41
51
  },
42
52
  ],
@@ -56,6 +66,7 @@ describe('LibraryService', () => {
56
66
  id: novel.id,
57
67
  type: 'novel',
58
68
  title: 'book',
69
+ addedAt: expect.any(String),
59
70
  directoryId: 'novel-root',
60
71
  filename: 'book.txt',
61
72
  progress: null,
@@ -74,8 +85,12 @@ describe('LibraryService', () => {
74
85
  id: comic.id,
75
86
  type: 'comic',
76
87
  title: 'manga',
88
+ addedAt: expect.any(String),
77
89
  directoryId: 'comic-root',
78
- pages: ['1.jpg', '2.jpg'],
90
+ pages: [
91
+ { filename: '1.jpg', width: 300, height: 400 },
92
+ { filename: '2.jpg', width: 300, height: 400 },
93
+ ],
79
94
  progress: null,
80
95
  });
81
96
  expect(comic).toMatchObject({
@@ -1,8 +1,9 @@
1
+ import { normalizeComicProgress } from './schema.js';
1
2
  function novelProgress(entry) {
2
3
  return entry?.type === 'novel' ? entry : null;
3
4
  }
4
- function comicProgress(entry) {
5
- return entry?.type === 'comic' ? entry : null;
5
+ function comicProgress(entry, pageCount) {
6
+ return normalizeComicProgress(entry, pageCount);
6
7
  }
7
8
  export function attachProgressToListItem(item, progressMap) {
8
9
  const entry = progressMap[item.id] ?? null;
@@ -14,7 +15,7 @@ export function attachProgressToListItem(item, progressMap) {
14
15
  }
15
16
  return {
16
17
  ...item,
17
- progress: comicProgress(entry),
18
+ progress: comicProgress(entry, item.pageCount),
18
19
  };
19
20
  }
20
21
  export function attachProgressToDetail(detail, progressMap) {
@@ -27,6 +28,6 @@ export function attachProgressToDetail(detail, progressMap) {
27
28
  }
28
29
  return {
29
30
  ...detail,
30
- progress: comicProgress(entry),
31
+ progress: comicProgress(entry, detail.pages.length),
31
32
  };
32
33
  }
@@ -1,5 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  const comicProgressSchema = z.object({
3
+ type: z.literal('comic'),
4
+ scrollRatio: z.number().min(0).max(1),
5
+ updatedAt: z.string().datetime(),
6
+ completedAt: z.string().datetime().nullable().optional(),
7
+ });
8
+ const legacyComicProgressSchema = z.object({
3
9
  type: z.literal('comic'),
4
10
  pageIndex: z.number().int().min(0),
5
11
  updatedAt: z.string().datetime(),
@@ -18,16 +24,21 @@ const novelProgressSchema = z.object({
18
24
  .optional(),
19
25
  completedAt: z.string().datetime().nullable().optional(),
20
26
  });
27
+ const storedProgressEntrySchema = z.union([
28
+ comicProgressSchema,
29
+ legacyComicProgressSchema,
30
+ novelProgressSchema,
31
+ ]);
21
32
  export const progressEntrySchema = z.discriminatedUnion('type', [
22
33
  comicProgressSchema,
23
34
  novelProgressSchema,
24
35
  ]);
25
36
  const progressStoreSchema = z.object({
26
- items: z.record(z.string(), progressEntrySchema),
37
+ items: z.record(z.string(), storedProgressEntrySchema),
27
38
  });
28
39
  export const comicProgressInputSchema = z.object({
29
40
  type: z.literal('comic'),
30
- pageIndex: z.number().int().min(0),
41
+ scrollRatio: z.number().min(0).max(1),
31
42
  completedAt: z.string().datetime().nullable().optional(),
32
43
  });
33
44
  export const novelProgressInputSchema = z.object({
@@ -46,6 +57,27 @@ export const progressInputSchema = z.discriminatedUnion('type', [
46
57
  comicProgressInputSchema,
47
58
  novelProgressInputSchema,
48
59
  ]);
60
+ export function migratePageIndexToScrollRatio(pageIndex, pageCount) {
61
+ if (pageCount <= 1) {
62
+ return 0;
63
+ }
64
+ const clamped = Math.min(Math.max(pageIndex, 0), pageCount - 1);
65
+ return clamped / (pageCount - 1);
66
+ }
67
+ export function normalizeComicProgress(entry, pageCount) {
68
+ if (!entry || entry.type !== 'comic') {
69
+ return null;
70
+ }
71
+ if ('scrollRatio' in entry) {
72
+ return entry;
73
+ }
74
+ return {
75
+ type: 'comic',
76
+ scrollRatio: migratePageIndexToScrollRatio(entry.pageIndex, pageCount),
77
+ updatedAt: entry.updatedAt,
78
+ ...(entry.completedAt !== undefined ? { completedAt: entry.completedAt } : {}),
79
+ };
80
+ }
49
81
  export function createEmptyProgressStore() {
50
82
  return { items: {} };
51
83
  }
@@ -62,5 +94,9 @@ export function parseProgressStore(data) {
62
94
  if (!parsed.success) {
63
95
  return createEmptyProgressStore();
64
96
  }
65
- return parsed.data;
97
+ const items = {};
98
+ for (const [itemId, entry] of Object.entries(parsed.data.items)) {
99
+ items[itemId] = entry;
100
+ }
101
+ return { items };
66
102
  }
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { parseProgressStore, progressInputSchema } from './schema.js';
2
+ import { migratePageIndexToScrollRatio, normalizeComicProgress, parseProgressStore, progressInputSchema, } from './schema.js';
3
3
  describe('progress schema', () => {
4
4
  it('parses comic and novel entries', () => {
5
5
  const store = parseProgressStore({
6
6
  items: {
7
7
  comic1: {
8
8
  type: 'comic',
9
- pageIndex: 2,
9
+ scrollRatio: 0.5,
10
10
  updatedAt: '2026-01-01T00:00:00.000Z',
11
11
  },
12
12
  novel1: {
@@ -21,6 +21,39 @@ describe('progress schema', () => {
21
21
  expect(store.items.comic1?.type).toBe('comic');
22
22
  expect(store.items.novel1?.type === 'novel' ? store.items.novel1.paragraphIndex : null).toBe(10);
23
23
  });
24
+ it('parses legacy comic entries with pageIndex', () => {
25
+ const store = parseProgressStore({
26
+ items: {
27
+ comic1: {
28
+ type: 'comic',
29
+ pageIndex: 2,
30
+ updatedAt: '2026-01-01T00:00:00.000Z',
31
+ },
32
+ },
33
+ });
34
+ expect(store.items.comic1).toEqual({
35
+ type: 'comic',
36
+ pageIndex: 2,
37
+ updatedAt: '2026-01-01T00:00:00.000Z',
38
+ });
39
+ });
40
+ it('normalizes legacy comic progress', () => {
41
+ expect(normalizeComicProgress({
42
+ type: 'comic',
43
+ pageIndex: 2,
44
+ updatedAt: '2026-01-01T00:00:00.000Z',
45
+ }, 5)).toEqual({
46
+ type: 'comic',
47
+ scrollRatio: 0.5,
48
+ updatedAt: '2026-01-01T00:00:00.000Z',
49
+ });
50
+ });
51
+ it('migrates page index to scroll ratio', () => {
52
+ expect(migratePageIndexToScrollRatio(0, 5)).toBe(0);
53
+ expect(migratePageIndexToScrollRatio(4, 5)).toBe(1);
54
+ expect(migratePageIndexToScrollRatio(2, 5)).toBe(0.5);
55
+ expect(migratePageIndexToScrollRatio(0, 1)).toBe(0);
56
+ });
24
57
  it('treats missing items as an empty store', () => {
25
58
  expect(parseProgressStore({})).toEqual({ items: {} });
26
59
  expect(parseProgressStore(null)).toEqual({ items: {} });
@@ -28,7 +61,7 @@ describe('progress schema', () => {
28
61
  it('validates progress input', () => {
29
62
  expect(progressInputSchema.safeParse({
30
63
  type: 'comic',
31
- pageIndex: 0,
64
+ scrollRatio: 0.25,
32
65
  completedAt: null,
33
66
  }).success).toBe(true);
34
67
  expect(progressInputSchema.safeParse({
@@ -59,11 +59,10 @@ export class ProgressService {
59
59
  if (detail.type !== 'comic') {
60
60
  throw new TypeError('Progress type does not match library item type');
61
61
  }
62
- const pageCount = detail.pages.length;
63
- const pageIndex = pageCount === 0 ? 0 : Math.min(input.pageIndex, pageCount - 1);
62
+ const scrollRatio = Math.min(Math.max(input.scrollRatio, 0), 1);
64
63
  return {
65
64
  type: 'comic',
66
- pageIndex,
65
+ scrollRatio,
67
66
  updatedAt,
68
67
  ...(input.completedAt !== undefined ? { completedAt: input.completedAt } : {}),
69
68
  };