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.
- package/bundle/server/dist/app.js +23 -0
- package/bundle/server/dist/app.test.js +9 -0
- package/bundle/server/dist/config/defaults.js +7 -0
- package/bundle/server/dist/config/paths.js +18 -0
- package/bundle/server/dist/config/schema.js +84 -0
- package/bundle/server/dist/config/schema.test.js +76 -0
- package/bundle/server/dist/config/service.js +38 -0
- package/bundle/server/dist/config/storage.js +54 -0
- package/bundle/server/dist/config/storage.test.js +79 -0
- package/bundle/server/dist/index.js +28 -0
- package/bundle/server/dist/library/comic-url.js +4 -0
- package/bundle/server/dist/library/cover.js +44 -0
- package/bundle/server/dist/library/errors.js +12 -0
- package/bundle/server/dist/library/files.js +35 -0
- package/bundle/server/dist/library/files.test.js +29 -0
- package/bundle/server/dist/library/id.js +6 -0
- package/bundle/server/dist/library/novel-url.js +4 -0
- package/bundle/server/dist/library/scan.js +132 -0
- package/bundle/server/dist/library/scan.test.js +252 -0
- package/bundle/server/dist/library/service.js +87 -0
- package/bundle/server/dist/library/service.test.js +92 -0
- package/bundle/server/dist/library/test-images.js +23 -0
- package/bundle/server/dist/library/text-encoding.js +33 -0
- package/bundle/server/dist/library/text-encoding.test.js +42 -0
- package/bundle/server/dist/library/types.js +1 -0
- package/bundle/server/dist/library-static.js +75 -0
- package/bundle/server/dist/library-static.test.js +81 -0
- package/bundle/server/dist/listen-config.js +23 -0
- package/bundle/server/dist/listen-config.test.js +50 -0
- package/bundle/server/dist/reading/progress/merge.js +32 -0
- package/bundle/server/dist/reading/progress/schema.js +66 -0
- package/bundle/server/dist/reading/progress/schema.test.js +39 -0
- package/bundle/server/dist/reading/progress/service.js +81 -0
- package/bundle/server/dist/reading/progress/service.test.js +104 -0
- package/bundle/server/dist/reading/progress/storage.js +44 -0
- package/bundle/server/dist/reading/recent/schema.js +16 -0
- package/bundle/server/dist/reading/recent/service.js +54 -0
- package/bundle/server/dist/reading/recent/service.test.js +84 -0
- package/bundle/server/dist/reading/recent/storage.js +42 -0
- package/bundle/server/dist/reading/recent/storage.test.js +46 -0
- package/bundle/server/dist/routes/config.js +52 -0
- package/bundle/server/dist/routes/config.test.js +147 -0
- package/bundle/server/dist/routes/library.js +31 -0
- package/bundle/server/dist/routes/library.test.js +125 -0
- package/bundle/server/dist/routes/reading.js +66 -0
- package/bundle/server/dist/routes/reading.test.js +135 -0
- package/bundle/server/dist/web-static.js +31 -0
- package/bundle/server/dist/web-static.test.js +92 -0
- package/bundle/web/dist/assets/_shell-DPQLbvwD.js +5 -0
- package/bundle/web/dist/assets/_shell-gkEQU-gF.js +1 -0
- package/bundle/web/dist/assets/comic._id-BjwrciUv.js +1 -0
- package/bundle/web/dist/assets/dist-Da_WaNYN.js +1 -0
- 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 +1 -0
- package/bundle/web/dist/assets/index-BpBtuR9k.css +2 -0
- package/bundle/web/dist/assets/index-CaXGEWDb.js +10 -0
- package/bundle/web/dist/assets/input-DcKYfXao.js +41 -0
- package/bundle/web/dist/assets/library-Dw1qyl0v.js +1 -0
- package/bundle/web/dist/assets/mutation-CwzbY9ri.js +1 -0
- package/bundle/web/dist/assets/novel._id-DsL8say-.js +4 -0
- package/bundle/web/dist/assets/query-keys-CDsPIOEO.js +1 -0
- package/bundle/web/dist/assets/reading-BseNE7P3.js +1 -0
- package/bundle/web/dist/assets/reading-progress-BcmbUd_d.js +1 -0
- package/bundle/web/dist/assets/save-reading-progress-AuKXiup6.js +1 -0
- package/bundle/web/dist/assets/scroll-area-Bzi-DiTy.js +1 -0
- package/bundle/web/dist/assets/settings-2-CCv0KiQI.js +1 -0
- package/bundle/web/dist/assets/settings-2wiLHSOF.js +1 -0
- package/bundle/web/dist/assets/shell-context-BT0BT8PF.js +1 -0
- package/bundle/web/dist/assets/toggle-group-DDKezi4R.js +1 -0
- package/bundle/web/dist/assets/useNavigate-BMyovDLu.js +1 -0
- package/bundle/web/dist/assets/utils-BmM72imW.js +1 -0
- package/bundle/web/dist/favicon.svg +1 -0
- package/bundle/web/dist/index.html +17 -0
- package/bundle/web/dist/novel-default-cover.png +0 -0
- package/dist/cli.js +40 -0
- package/dist/parse-options.js +30 -0
- package/dist/parse-options.test.js +26 -0
- package/dist/paths.js +11 -0
- package/dist/paths.test.js +13 -0
- package/dist/spawn-server.js +40 -0
- package/dist/spawn-server.test.js +46 -0
- package/package.json +32 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { generateComicCoverIfMissing, resolveComicCoverFilename } from './cover.js';
|
|
4
|
+
import { isComicCoverFile, isHiddenName, isImageFile, isTxtFile, sortByNaturalName, } from './files.js';
|
|
5
|
+
import { makeLocalItemId } from './id.js';
|
|
6
|
+
import { convertGbkBufferToUtf8, detectTextEncoding, writeUtf8FileAtomically, } from './text-encoding.js';
|
|
7
|
+
async function listDirectChildren(dir) {
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = await readdir(dir);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return entries.filter((name) => !isHiddenName(name)).map((name) => path.join(dir, name));
|
|
16
|
+
}
|
|
17
|
+
function resolveGbkNovelHandling(handling) {
|
|
18
|
+
return handling === 'convert-to-utf-8' ? 'convert-to-utf-8' : 'ignore';
|
|
19
|
+
}
|
|
20
|
+
async function shouldIncludeNovel(childPath, gbkNovelHandling) {
|
|
21
|
+
const handling = resolveGbkNovelHandling(gbkNovelHandling);
|
|
22
|
+
let buffer;
|
|
23
|
+
try {
|
|
24
|
+
buffer = await readFile(childPath);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error(`Failed to read novel file: ${childPath}`, error);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const encoding = detectTextEncoding(buffer);
|
|
31
|
+
if (encoding === 'utf-8') {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (handling === 'ignore') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const text = convertGbkBufferToUtf8(buffer);
|
|
39
|
+
await writeUtf8FileAtomically(childPath, text);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error(`Failed to convert GBK novel to UTF-8: ${childPath}`, error);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling) {
|
|
48
|
+
const children = await listDirectChildren(root);
|
|
49
|
+
const stats = await Promise.all(children.map(async (childPath) => ({
|
|
50
|
+
childPath,
|
|
51
|
+
childStat: await stat(childPath).catch(() => null),
|
|
52
|
+
})));
|
|
53
|
+
const txtFiles = stats.filter(({ childStat, childPath }) => {
|
|
54
|
+
if (!childStat?.isFile()) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return isTxtFile(path.basename(childPath));
|
|
58
|
+
});
|
|
59
|
+
const inclusion = await Promise.all(txtFiles.map(async ({ childPath }) => ({
|
|
60
|
+
childPath,
|
|
61
|
+
include: await shouldIncludeNovel(childPath, gbkNovelHandling),
|
|
62
|
+
})));
|
|
63
|
+
return inclusion
|
|
64
|
+
.filter((entry) => entry.include)
|
|
65
|
+
.map(({ childPath }) => {
|
|
66
|
+
const name = path.basename(childPath);
|
|
67
|
+
const title = path.basename(name, path.extname(name));
|
|
68
|
+
return {
|
|
69
|
+
id: makeLocalItemId(childPath),
|
|
70
|
+
type: 'novel',
|
|
71
|
+
title,
|
|
72
|
+
path: childPath,
|
|
73
|
+
directoryId,
|
|
74
|
+
filename: name,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async function listDirectImageFiles(dir) {
|
|
79
|
+
const children = await listDirectChildren(dir);
|
|
80
|
+
const stats = await Promise.all(children.map(async (childPath) => ({
|
|
81
|
+
childPath,
|
|
82
|
+
childStat: await stat(childPath).catch(() => null),
|
|
83
|
+
})));
|
|
84
|
+
const images = [];
|
|
85
|
+
for (const { childPath, childStat } of stats) {
|
|
86
|
+
if (!childStat?.isFile()) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const name = path.basename(childPath);
|
|
90
|
+
if (!isImageFile(name) || isComicCoverFile(name)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
images.push(childPath);
|
|
94
|
+
}
|
|
95
|
+
return sortByNaturalName(images);
|
|
96
|
+
}
|
|
97
|
+
async function scanComicsInDirectory(directoryId, root) {
|
|
98
|
+
const children = await listDirectChildren(root);
|
|
99
|
+
const stats = await Promise.all(children.map(async (childPath) => ({
|
|
100
|
+
childPath,
|
|
101
|
+
childStat: await stat(childPath).catch(() => null),
|
|
102
|
+
})));
|
|
103
|
+
const comicDirs = stats
|
|
104
|
+
.filter(({ childStat }) => childStat?.isDirectory())
|
|
105
|
+
.map(({ childPath }) => childPath);
|
|
106
|
+
const comics = await Promise.all(comicDirs.map(async (childPath) => {
|
|
107
|
+
const images = await listDirectImageFiles(childPath);
|
|
108
|
+
if (images.length === 0) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
await generateComicCoverIfMissing(childPath, images[0]);
|
|
112
|
+
const coverFilename = await resolveComicCoverFilename(childPath);
|
|
113
|
+
return {
|
|
114
|
+
id: makeLocalItemId(childPath),
|
|
115
|
+
type: 'comic',
|
|
116
|
+
title: path.basename(childPath),
|
|
117
|
+
path: childPath,
|
|
118
|
+
directoryId,
|
|
119
|
+
pages: images.map((imagePath) => path.basename(imagePath)),
|
|
120
|
+
coverFilename,
|
|
121
|
+
};
|
|
122
|
+
}));
|
|
123
|
+
return comics.filter((comic) => comic !== null);
|
|
124
|
+
}
|
|
125
|
+
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()];
|
|
129
|
+
}
|
|
130
|
+
export async function listComicPages(comicDir) {
|
|
131
|
+
return listDirectImageFiles(comicDir);
|
|
132
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { COMIC_COVER_FILENAME } from './files.js';
|
|
7
|
+
import { listComicPages, scanLibrary } from './scan.js';
|
|
8
|
+
import { writeTestImage } from './test-images.js';
|
|
9
|
+
describe('scanLibrary', () => {
|
|
10
|
+
let tempDir;
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-scan-'));
|
|
13
|
+
});
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
it('ignores GBK txt files when gbkNovelHandling is undefined', async () => {
|
|
18
|
+
const novelRoot = path.join(tempDir, 'novels');
|
|
19
|
+
const filePath = path.join(novelRoot, 'gbk.txt');
|
|
20
|
+
await mkdir(novelRoot);
|
|
21
|
+
await writeFile(filePath, Buffer.from([0xd6, 0xd0, 0xce, 0xc4]));
|
|
22
|
+
const items = await scanLibrary({
|
|
23
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
24
|
+
comicDirectories: [],
|
|
25
|
+
});
|
|
26
|
+
expect(items).toHaveLength(0);
|
|
27
|
+
expect(Buffer.from(await readFile(filePath))).toEqual(Buffer.from([0xd6, 0xd0, 0xce, 0xc4]));
|
|
28
|
+
});
|
|
29
|
+
it('ignores GBK txt files when gbkNovelHandling is ignore', async () => {
|
|
30
|
+
const novelRoot = path.join(tempDir, 'novels');
|
|
31
|
+
await mkdir(novelRoot);
|
|
32
|
+
await writeFile(path.join(novelRoot, 'gbk.txt'), Buffer.from([0xd6, 0xd0, 0xce, 0xc4]));
|
|
33
|
+
const items = await scanLibrary({
|
|
34
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
35
|
+
comicDirectories: [],
|
|
36
|
+
gbkNovelHandling: 'ignore',
|
|
37
|
+
});
|
|
38
|
+
expect(items).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
it('converts GBK txt files to UTF-8 when gbkNovelHandling is convert-to-utf-8', async () => {
|
|
41
|
+
const novelRoot = path.join(tempDir, 'novels');
|
|
42
|
+
const filePath = path.join(novelRoot, 'gbk.txt');
|
|
43
|
+
await mkdir(novelRoot);
|
|
44
|
+
await writeFile(filePath, Buffer.from([0xd6, 0xd0, 0xce, 0xc4]));
|
|
45
|
+
const items = await scanLibrary({
|
|
46
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
47
|
+
comicDirectories: [],
|
|
48
|
+
gbkNovelHandling: 'convert-to-utf-8',
|
|
49
|
+
});
|
|
50
|
+
expect(items).toHaveLength(1);
|
|
51
|
+
expect(items[0]).toMatchObject({ type: 'novel', title: 'gbk' });
|
|
52
|
+
expect(await readFile(filePath, 'utf-8')).toBe('中文');
|
|
53
|
+
});
|
|
54
|
+
it('scans direct txt files as novels', async () => {
|
|
55
|
+
const novelRoot = path.join(tempDir, 'novels');
|
|
56
|
+
await mkdir(novelRoot);
|
|
57
|
+
await writeFile(path.join(novelRoot, 'alpha.txt'), 'content');
|
|
58
|
+
await writeFile(path.join(novelRoot, 'BETA.TXT'), 'content');
|
|
59
|
+
const items = await scanLibrary({
|
|
60
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
61
|
+
comicDirectories: [],
|
|
62
|
+
gbkNovelHandling: 'ignore',
|
|
63
|
+
});
|
|
64
|
+
expect(items).toHaveLength(2);
|
|
65
|
+
expect(items).toEqual(expect.arrayContaining([
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
type: 'novel',
|
|
68
|
+
title: 'alpha',
|
|
69
|
+
directoryId: 'novel-root',
|
|
70
|
+
filename: 'alpha.txt',
|
|
71
|
+
}),
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
type: 'novel',
|
|
74
|
+
title: 'BETA',
|
|
75
|
+
directoryId: 'novel-root',
|
|
76
|
+
filename: 'BETA.TXT',
|
|
77
|
+
}),
|
|
78
|
+
]));
|
|
79
|
+
});
|
|
80
|
+
it('ignores txt files in subdirectories and hidden txt files', async () => {
|
|
81
|
+
const novelRoot = path.join(tempDir, 'novels');
|
|
82
|
+
const nested = path.join(novelRoot, 'nested');
|
|
83
|
+
await mkdir(nested, { recursive: true });
|
|
84
|
+
await writeFile(path.join(novelRoot, 'visible.txt'), 'content');
|
|
85
|
+
await writeFile(path.join(nested, 'hidden-nested.txt'), 'content');
|
|
86
|
+
await writeFile(path.join(novelRoot, '.hidden.txt'), 'content');
|
|
87
|
+
const items = await scanLibrary({
|
|
88
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
89
|
+
comicDirectories: [],
|
|
90
|
+
gbkNovelHandling: 'ignore',
|
|
91
|
+
});
|
|
92
|
+
expect(items).toHaveLength(1);
|
|
93
|
+
expect(items[0]).toMatchObject({ type: 'novel', title: 'visible' });
|
|
94
|
+
});
|
|
95
|
+
it('scans direct child folders with direct images as comics', async () => {
|
|
96
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
97
|
+
const mangaDir = path.join(comicRoot, 'manga-a');
|
|
98
|
+
const nested = path.join(mangaDir, 'chapter');
|
|
99
|
+
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'), '');
|
|
104
|
+
const items = await scanLibrary({
|
|
105
|
+
novelDirectories: [],
|
|
106
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
107
|
+
gbkNovelHandling: 'ignore',
|
|
108
|
+
});
|
|
109
|
+
expect(items).toHaveLength(1);
|
|
110
|
+
expect(items[0]).toMatchObject({
|
|
111
|
+
type: 'comic',
|
|
112
|
+
title: 'manga-a',
|
|
113
|
+
directoryId: 'comic-root',
|
|
114
|
+
pages: ['1.webp', '2.png', '10.jpg'],
|
|
115
|
+
coverFilename: null,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
it('ignores root-level images and folders without direct images', async () => {
|
|
119
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
120
|
+
const emptyDir = path.join(comicRoot, 'empty');
|
|
121
|
+
await mkdir(emptyDir, { recursive: true });
|
|
122
|
+
await writeFile(path.join(comicRoot, 'root.jpg'), '');
|
|
123
|
+
await mkdir(path.join(emptyDir, 'nested'), { recursive: true });
|
|
124
|
+
await writeFile(path.join(emptyDir, 'nested', '1.jpg'), '');
|
|
125
|
+
const items = await scanLibrary({
|
|
126
|
+
novelDirectories: [],
|
|
127
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
128
|
+
gbkNovelHandling: 'ignore',
|
|
129
|
+
});
|
|
130
|
+
expect(items).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
it('generates cover.jpg from the first page during scan', async () => {
|
|
133
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
134
|
+
const mangaDir = path.join(comicRoot, 'manga-cover');
|
|
135
|
+
await mkdir(mangaDir, { recursive: true });
|
|
136
|
+
await writeTestImage(path.join(mangaDir, '2.jpg'), 301, 400);
|
|
137
|
+
await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 399);
|
|
138
|
+
const items = await scanLibrary({
|
|
139
|
+
novelDirectories: [],
|
|
140
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
141
|
+
gbkNovelHandling: 'ignore',
|
|
142
|
+
});
|
|
143
|
+
const coverPath = path.join(mangaDir, COMIC_COVER_FILENAME);
|
|
144
|
+
await access(coverPath);
|
|
145
|
+
const coverMetadata = await sharp(coverPath).metadata();
|
|
146
|
+
expect(coverMetadata.width).toBe(100);
|
|
147
|
+
expect(coverMetadata.height).toBe(133);
|
|
148
|
+
expect(items).toHaveLength(1);
|
|
149
|
+
expect(items[0]).toMatchObject({
|
|
150
|
+
type: 'comic',
|
|
151
|
+
title: 'manga-cover',
|
|
152
|
+
pages: ['1.jpg', '2.jpg'],
|
|
153
|
+
coverFilename: COMIC_COVER_FILENAME,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
it('does not regenerate cover.jpg when it already exists', async () => {
|
|
157
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
158
|
+
const mangaDir = path.join(comicRoot, 'manga-existing-cover');
|
|
159
|
+
await mkdir(mangaDir, { recursive: true });
|
|
160
|
+
await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 300);
|
|
161
|
+
const coverPath = path.join(mangaDir, COMIC_COVER_FILENAME);
|
|
162
|
+
await writeFile(coverPath, 'existing-cover');
|
|
163
|
+
const beforeMtime = (await stat(coverPath)).mtimeMs;
|
|
164
|
+
await scanLibrary({
|
|
165
|
+
novelDirectories: [],
|
|
166
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
167
|
+
gbkNovelHandling: 'ignore',
|
|
168
|
+
});
|
|
169
|
+
expect((await stat(coverPath)).mtimeMs).toBe(beforeMtime);
|
|
170
|
+
expect(await readFile(coverPath, 'utf-8')).toBe('existing-cover');
|
|
171
|
+
});
|
|
172
|
+
it('excludes cover.jpg from comic pages', async () => {
|
|
173
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
174
|
+
const mangaDir = path.join(comicRoot, 'manga-pages');
|
|
175
|
+
await mkdir(mangaDir, { recursive: true });
|
|
176
|
+
await writeTestImage(path.join(mangaDir, '2.jpg'), 300, 300);
|
|
177
|
+
await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 300);
|
|
178
|
+
const items = await scanLibrary({
|
|
179
|
+
novelDirectories: [],
|
|
180
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
181
|
+
gbkNovelHandling: 'ignore',
|
|
182
|
+
});
|
|
183
|
+
expect(items[0]).toMatchObject({
|
|
184
|
+
type: 'comic',
|
|
185
|
+
pages: ['1.jpg', '2.jpg'],
|
|
186
|
+
coverFilename: COMIC_COVER_FILENAME,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
it('keeps scanning comics when cover generation fails', async () => {
|
|
190
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
191
|
+
const mangaDir = path.join(comicRoot, 'manga-invalid');
|
|
192
|
+
await mkdir(mangaDir, { recursive: true });
|
|
193
|
+
await writeFile(path.join(mangaDir, '1.jpg'), '');
|
|
194
|
+
const items = await scanLibrary({
|
|
195
|
+
novelDirectories: [],
|
|
196
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
197
|
+
gbkNovelHandling: 'ignore',
|
|
198
|
+
});
|
|
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
|
+
});
|
|
206
|
+
await expect(access(path.join(mangaDir, COMIC_COVER_FILENAME))).rejects.toThrow();
|
|
207
|
+
});
|
|
208
|
+
it('supports jpeg extension for comic cover', async () => {
|
|
209
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
210
|
+
const mangaDir = path.join(comicRoot, 'manga-b');
|
|
211
|
+
await mkdir(mangaDir, { recursive: true });
|
|
212
|
+
await writeFile(path.join(mangaDir, 'cover.jpeg'), '');
|
|
213
|
+
const items = await scanLibrary({
|
|
214
|
+
novelDirectories: [],
|
|
215
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
216
|
+
gbkNovelHandling: 'ignore',
|
|
217
|
+
});
|
|
218
|
+
expect(items).toHaveLength(1);
|
|
219
|
+
expect(items[0]).toMatchObject({
|
|
220
|
+
type: 'comic',
|
|
221
|
+
directoryId: 'comic-root',
|
|
222
|
+
pages: ['cover.jpeg'],
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe('listComicPages', () => {
|
|
227
|
+
let tempDir;
|
|
228
|
+
beforeEach(async () => {
|
|
229
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-pages-'));
|
|
230
|
+
});
|
|
231
|
+
afterEach(async () => {
|
|
232
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
233
|
+
});
|
|
234
|
+
it('returns direct image files in natural order', async () => {
|
|
235
|
+
await writeFile(path.join(tempDir, '10.jpg'), '');
|
|
236
|
+
await writeFile(path.join(tempDir, '2.jpg'), '');
|
|
237
|
+
await writeFile(path.join(tempDir, '1.jpg'), '');
|
|
238
|
+
const pages = await listComicPages(tempDir);
|
|
239
|
+
expect(pages).toEqual([
|
|
240
|
+
path.join(tempDir, '1.jpg'),
|
|
241
|
+
path.join(tempDir, '2.jpg'),
|
|
242
|
+
path.join(tempDir, '10.jpg'),
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
it('excludes cover.jpg from comic pages', async () => {
|
|
246
|
+
await writeFile(path.join(tempDir, COMIC_COVER_FILENAME), '');
|
|
247
|
+
await writeFile(path.join(tempDir, '2.jpg'), '');
|
|
248
|
+
await writeFile(path.join(tempDir, '1.jpg'), '');
|
|
249
|
+
const pages = await listComicPages(tempDir);
|
|
250
|
+
expect(pages).toEqual([path.join(tempDir, '1.jpg'), path.join(tempDir, '2.jpg')]);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { attachProgressToDetail, attachProgressToListItem } from '../reading/progress/merge.js';
|
|
2
|
+
import { buildComicImageUrl } from './comic-url.js';
|
|
3
|
+
import { NotFoundError, ScanInProgressError } from './errors.js';
|
|
4
|
+
import { scanLibrary } from './scan.js';
|
|
5
|
+
export function toListItemBase(item) {
|
|
6
|
+
if (item.type === 'novel') {
|
|
7
|
+
return {
|
|
8
|
+
id: item.id,
|
|
9
|
+
type: 'novel',
|
|
10
|
+
title: item.title,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const coverPage = item.coverFilename ?? item.pages[0];
|
|
14
|
+
return {
|
|
15
|
+
id: item.id,
|
|
16
|
+
type: 'comic',
|
|
17
|
+
title: item.title,
|
|
18
|
+
cover: coverPage ? buildComicImageUrl(item.directoryId, item.title, coverPage) : '',
|
|
19
|
+
pageCount: item.pages.length,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function toItemDetailBase(item) {
|
|
23
|
+
if (item.type === 'novel') {
|
|
24
|
+
return {
|
|
25
|
+
id: item.id,
|
|
26
|
+
type: 'novel',
|
|
27
|
+
title: item.title,
|
|
28
|
+
directoryId: item.directoryId,
|
|
29
|
+
filename: item.filename,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
id: item.id,
|
|
34
|
+
type: 'comic',
|
|
35
|
+
title: item.title,
|
|
36
|
+
directoryId: item.directoryId,
|
|
37
|
+
pages: item.pages,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export class LibraryService {
|
|
41
|
+
getConfig;
|
|
42
|
+
items = [];
|
|
43
|
+
scanning = false;
|
|
44
|
+
constructor(getConfig) {
|
|
45
|
+
this.getConfig = getConfig;
|
|
46
|
+
}
|
|
47
|
+
getState(progressMap = {}) {
|
|
48
|
+
return {
|
|
49
|
+
scanning: this.scanning,
|
|
50
|
+
items: this.items.map((item) => attachProgressToListItem(toListItemBase(item), progressMap)),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
getListItem(id, progressMap = {}) {
|
|
54
|
+
const item = this.items.find((entry) => entry.id === id);
|
|
55
|
+
return item ? attachProgressToListItem(toListItemBase(item), progressMap) : null;
|
|
56
|
+
}
|
|
57
|
+
isScanning() {
|
|
58
|
+
return this.scanning;
|
|
59
|
+
}
|
|
60
|
+
async scan() {
|
|
61
|
+
if (this.scanning) {
|
|
62
|
+
throw new ScanInProgressError();
|
|
63
|
+
}
|
|
64
|
+
this.scanning = true;
|
|
65
|
+
try {
|
|
66
|
+
this.items = await scanLibrary(this.getConfig());
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
this.scanning = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
tryScan() {
|
|
73
|
+
if (this.scanning) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
void this.scan().catch((error) => {
|
|
77
|
+
console.error('Library scan failed:', error);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
getItemDetail(id, progressMap = {}) {
|
|
81
|
+
const item = this.items.find((entry) => entry.id === id);
|
|
82
|
+
if (!item) {
|
|
83
|
+
throw new NotFoundError('Item not found');
|
|
84
|
+
}
|
|
85
|
+
return attachProgressToDetail(toItemDetailBase(item), progressMap);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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, ScanInProgressError } from './errors.js';
|
|
6
|
+
import { LibraryService } from './service.js';
|
|
7
|
+
import { writeTestImage } from './test-images.js';
|
|
8
|
+
describe('LibraryService', () => {
|
|
9
|
+
let tempDir;
|
|
10
|
+
let novelRoot;
|
|
11
|
+
let comicRoot;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-service-'));
|
|
14
|
+
novelRoot = path.join(tempDir, 'novels');
|
|
15
|
+
comicRoot = path.join(tempDir, 'comics');
|
|
16
|
+
await mkdir(novelRoot);
|
|
17
|
+
await mkdir(comicRoot);
|
|
18
|
+
await writeFile(path.join(novelRoot, 'book.txt'), 'content');
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
function createService() {
|
|
24
|
+
return new LibraryService(() => ({
|
|
25
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
26
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
27
|
+
gbkNovelHandling: 'ignore',
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
it('populates list items after scan', async () => {
|
|
31
|
+
const service = createService();
|
|
32
|
+
await service.scan();
|
|
33
|
+
expect(service.getState()).toEqual({
|
|
34
|
+
scanning: false,
|
|
35
|
+
items: [
|
|
36
|
+
{
|
|
37
|
+
id: expect.any(String),
|
|
38
|
+
type: 'novel',
|
|
39
|
+
title: 'book',
|
|
40
|
+
progress: null,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it('rejects concurrent scan', async () => {
|
|
46
|
+
const service = createService();
|
|
47
|
+
const firstScan = service.scan();
|
|
48
|
+
await expect(service.scan()).rejects.toBeInstanceOf(ScanInProgressError);
|
|
49
|
+
await firstScan;
|
|
50
|
+
});
|
|
51
|
+
it('returns novel detail for a known id', async () => {
|
|
52
|
+
const service = createService();
|
|
53
|
+
await service.scan();
|
|
54
|
+
const novel = service.getState().items[0];
|
|
55
|
+
expect(service.getItemDetail(novel.id)).toEqual({
|
|
56
|
+
id: novel.id,
|
|
57
|
+
type: 'novel',
|
|
58
|
+
title: 'book',
|
|
59
|
+
directoryId: 'novel-root',
|
|
60
|
+
filename: 'book.txt',
|
|
61
|
+
progress: null,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
it('returns comic detail for a known id', async () => {
|
|
65
|
+
const mangaDir = path.join(comicRoot, 'manga');
|
|
66
|
+
await mkdir(mangaDir);
|
|
67
|
+
await writeTestImage(path.join(mangaDir, '2.jpg'), 300, 400);
|
|
68
|
+
await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 400);
|
|
69
|
+
const service = createService();
|
|
70
|
+
await service.scan();
|
|
71
|
+
const comic = service.getState().items.find((item) => item.type === 'comic');
|
|
72
|
+
expect(comic).toBeDefined();
|
|
73
|
+
expect(service.getItemDetail(comic.id)).toEqual({
|
|
74
|
+
id: comic.id,
|
|
75
|
+
type: 'comic',
|
|
76
|
+
title: 'manga',
|
|
77
|
+
directoryId: 'comic-root',
|
|
78
|
+
pages: ['1.jpg', '2.jpg'],
|
|
79
|
+
progress: null,
|
|
80
|
+
});
|
|
81
|
+
expect(comic).toMatchObject({
|
|
82
|
+
pageCount: 2,
|
|
83
|
+
cover: expect.stringMatching(/\/cover\.jpg$/),
|
|
84
|
+
progress: null,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
it('throws when detail is requested for unknown id', async () => {
|
|
88
|
+
const service = createService();
|
|
89
|
+
await service.scan();
|
|
90
|
+
expect(() => service.getItemDetail('missing')).toThrow(NotFoundError);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
export async function writeTestImage(filePath, width, height, format = 'jpeg') {
|
|
3
|
+
let pipeline = sharp({
|
|
4
|
+
create: {
|
|
5
|
+
width,
|
|
6
|
+
height,
|
|
7
|
+
channels: 3,
|
|
8
|
+
background: { r: 120, g: 80, b: 200 },
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
switch (format) {
|
|
12
|
+
case 'png':
|
|
13
|
+
pipeline = pipeline.png();
|
|
14
|
+
break;
|
|
15
|
+
case 'webp':
|
|
16
|
+
pipeline = pipeline.webp();
|
|
17
|
+
break;
|
|
18
|
+
default:
|
|
19
|
+
pipeline = pipeline.jpeg();
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
await pipeline.toFile(filePath);
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
const UTF8_BOM = [0xef, 0xbb, 0xbf];
|
|
3
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
4
|
+
const gb18030Decoder = new TextDecoder('gb18030');
|
|
5
|
+
function hasUtf8Bom(buffer) {
|
|
6
|
+
return (buffer.length >= 3 &&
|
|
7
|
+
buffer[0] === UTF8_BOM[0] &&
|
|
8
|
+
buffer[1] === UTF8_BOM[1] &&
|
|
9
|
+
buffer[2] === UTF8_BOM[2]);
|
|
10
|
+
}
|
|
11
|
+
function isValidUtf8(buffer) {
|
|
12
|
+
try {
|
|
13
|
+
utf8Decoder.decode(buffer);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function detectTextEncoding(buffer) {
|
|
21
|
+
if (hasUtf8Bom(buffer) || isValidUtf8(buffer)) {
|
|
22
|
+
return 'utf-8';
|
|
23
|
+
}
|
|
24
|
+
return 'gbk';
|
|
25
|
+
}
|
|
26
|
+
export function convertGbkBufferToUtf8(buffer) {
|
|
27
|
+
return gb18030Decoder.decode(buffer);
|
|
28
|
+
}
|
|
29
|
+
export async function writeUtf8FileAtomically(filePath, text) {
|
|
30
|
+
const tempPath = `${filePath}.cyreader-tmp`;
|
|
31
|
+
await writeFile(tempPath, text, 'utf-8');
|
|
32
|
+
await rename(tempPath, filePath);
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { 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 { convertGbkBufferToUtf8, detectTextEncoding, writeUtf8FileAtomically, } from './text-encoding.js';
|
|
6
|
+
describe('detectTextEncoding', () => {
|
|
7
|
+
it('detects UTF-8 BOM', () => {
|
|
8
|
+
const buffer = Buffer.from([0xef, 0xbb, 0xbf, 0x68, 0x69]);
|
|
9
|
+
expect(detectTextEncoding(buffer)).toBe('utf-8');
|
|
10
|
+
});
|
|
11
|
+
it('detects valid UTF-8 ASCII', () => {
|
|
12
|
+
expect(detectTextEncoding(Buffer.from('hello', 'utf-8'))).toBe('utf-8');
|
|
13
|
+
});
|
|
14
|
+
it('detects valid UTF-8 Chinese', () => {
|
|
15
|
+
expect(detectTextEncoding(Buffer.from('中文', 'utf-8'))).toBe('utf-8');
|
|
16
|
+
});
|
|
17
|
+
it('detects GBK bytes', () => {
|
|
18
|
+
const buffer = Buffer.from([0xd6, 0xd0, 0xce, 0xc4]);
|
|
19
|
+
expect(detectTextEncoding(buffer)).toBe('gbk');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('convertGbkBufferToUtf8', () => {
|
|
23
|
+
it('decodes GBK bytes to UTF-8 string', () => {
|
|
24
|
+
const buffer = Buffer.from([0xd6, 0xd0, 0xce, 0xc4]);
|
|
25
|
+
expect(convertGbkBufferToUtf8(buffer)).toBe('中文');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('writeUtf8FileAtomically', () => {
|
|
29
|
+
let tempDir;
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-text-encoding-'));
|
|
32
|
+
});
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
it('writes UTF-8 content and removes temp file', async () => {
|
|
37
|
+
const filePath = path.join(tempDir, 'novel.txt');
|
|
38
|
+
await writeUtf8FileAtomically(filePath, '中文');
|
|
39
|
+
expect(await readFile(filePath, 'utf-8')).toBe('中文');
|
|
40
|
+
await expect(readFile(`${filePath}.cyreader-tmp`, 'utf-8')).rejects.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|