cyreader 0.1.0 → 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.
- package/bundle/server/dist/config/paths.js +3 -0
- package/bundle/server/dist/library/cache.js +70 -0
- package/bundle/server/dist/library/scan.js +54 -8
- package/bundle/server/dist/library/scan.test.js +99 -18
- package/bundle/server/dist/library/service.js +5 -1
- package/bundle/server/dist/library/service.test.js +16 -1
- package/bundle/server/dist/reading/progress/merge.js +5 -4
- package/bundle/server/dist/reading/progress/schema.js +39 -3
- package/bundle/server/dist/reading/progress/schema.test.js +36 -3
- package/bundle/server/dist/reading/progress/service.js +2 -3
- package/bundle/server/dist/reading/progress/service.test.js +10 -9
- package/bundle/server/dist/routes/library.test.js +8 -1
- package/bundle/web/dist/assets/_shell-Bm2pxAQP.js +5 -0
- package/bundle/web/dist/assets/_shell-Cij8gMSv.js +1 -0
- package/bundle/web/dist/assets/comic._id-Djtr8diE.js +1 -0
- package/bundle/web/dist/assets/dist-DzLEGVNH.js +1 -0
- package/bundle/web/dist/assets/dist-QfKKfHSL.js +1 -0
- package/bundle/web/dist/assets/index-BHhqsr5H.js +10 -0
- package/bundle/web/dist/assets/index-D9hL3zrL.css +2 -0
- package/bundle/web/dist/assets/library-D_1sil_O.js +1 -0
- package/bundle/web/dist/assets/library-utils-BRrejTkM.js +1 -0
- package/bundle/web/dist/assets/mutation-ChAIwNv3.js +1 -0
- package/bundle/web/dist/assets/novel._id-C62VYvcl.js +4 -0
- package/bundle/web/dist/assets/query-keys-CbbgiZx4.js +1 -0
- package/bundle/web/dist/assets/reading-BozmJNi1.js +1 -0
- package/bundle/web/dist/assets/reading-progress-C5bkK-vF.js +1 -0
- package/bundle/web/dist/assets/save-reading-progress-D231ROaQ.js +1 -0
- package/bundle/web/dist/assets/scroll-area-CyEO8R_G.js +1 -0
- package/bundle/web/dist/assets/{settings-2-CCv0KiQI.js → settings-2-8mnAuSax.js} +1 -1
- package/bundle/web/dist/assets/settings-BRi0XyRg.js +1 -0
- package/bundle/web/dist/assets/shell-context-Buw4m44t.js +41 -0
- package/bundle/web/dist/assets/use-library-Bh7Bunn1.js +1 -0
- package/bundle/web/dist/assets/{useNavigate-BMyovDLu.js → useNavigate-B29ssGbr.js} +1 -1
- package/bundle/web/dist/assets/useRouterState-4dM5FKiy.js +1 -0
- package/bundle/web/dist/assets/utils-ChkMmPzE.js +1 -0
- package/bundle/web/dist/index.html +11 -5
- package/package.json +2 -2
- package/bundle/server/dist/app.test.js +0 -9
- package/bundle/web/dist/assets/_shell-DPQLbvwD.js +0 -5
- package/bundle/web/dist/assets/_shell-gkEQU-gF.js +0 -1
- package/bundle/web/dist/assets/comic._id-BjwrciUv.js +0 -1
- package/bundle/web/dist/assets/dist-Da_WaNYN.js +0 -1
- package/bundle/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/bundle/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/bundle/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/bundle/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/bundle/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/bundle/web/dist/assets/image-BZKAkGUy.js +0 -1
- package/bundle/web/dist/assets/index-BpBtuR9k.css +0 -2
- package/bundle/web/dist/assets/index-CaXGEWDb.js +0 -10
- package/bundle/web/dist/assets/input-DcKYfXao.js +0 -41
- package/bundle/web/dist/assets/library-Dw1qyl0v.js +0 -1
- package/bundle/web/dist/assets/mutation-CwzbY9ri.js +0 -1
- package/bundle/web/dist/assets/novel._id-DsL8say-.js +0 -4
- package/bundle/web/dist/assets/query-keys-CDsPIOEO.js +0 -1
- package/bundle/web/dist/assets/reading-BseNE7P3.js +0 -1
- package/bundle/web/dist/assets/reading-progress-BcmbUd_d.js +0 -1
- package/bundle/web/dist/assets/save-reading-progress-AuKXiup6.js +0 -1
- package/bundle/web/dist/assets/scroll-area-Bzi-DiTy.js +0 -1
- package/bundle/web/dist/assets/settings-2wiLHSOF.js +0 -1
- package/bundle/web/dist/assets/shell-context-BT0BT8PF.js +0 -1
- package/bundle/web/dist/assets/toggle-group-DDKezi4R.js +0 -1
- package/bundle/web/dist/assets/utils-BmM72imW.js +0 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
127
|
-
const
|
|
128
|
-
|
|
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
|
|
101
|
-
await
|
|
102
|
-
await
|
|
103
|
-
await
|
|
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: [
|
|
115
|
-
|
|
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: [
|
|
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: [
|
|
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('
|
|
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(
|
|
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
|
|
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: [
|
|
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
|
|
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(),
|
|
37
|
+
items: z.record(z.string(), storedProgressEntrySchema),
|
|
27
38
|
});
|
|
28
39
|
export const comicProgressInputSchema = z.object({
|
|
29
40
|
type: z.literal('comic'),
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
65
|
+
scrollRatio,
|
|
67
66
|
updatedAt,
|
|
68
67
|
...(input.completedAt !== undefined ? { completedAt: input.completedAt } : {}),
|
|
69
68
|
};
|