cyreader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/bundle/server/dist/app.js +23 -0
  2. package/bundle/server/dist/app.test.js +9 -0
  3. package/bundle/server/dist/config/defaults.js +7 -0
  4. package/bundle/server/dist/config/paths.js +18 -0
  5. package/bundle/server/dist/config/schema.js +84 -0
  6. package/bundle/server/dist/config/schema.test.js +76 -0
  7. package/bundle/server/dist/config/service.js +38 -0
  8. package/bundle/server/dist/config/storage.js +54 -0
  9. package/bundle/server/dist/config/storage.test.js +79 -0
  10. package/bundle/server/dist/index.js +28 -0
  11. package/bundle/server/dist/library/comic-url.js +4 -0
  12. package/bundle/server/dist/library/cover.js +44 -0
  13. package/bundle/server/dist/library/errors.js +12 -0
  14. package/bundle/server/dist/library/files.js +35 -0
  15. package/bundle/server/dist/library/files.test.js +29 -0
  16. package/bundle/server/dist/library/id.js +6 -0
  17. package/bundle/server/dist/library/novel-url.js +4 -0
  18. package/bundle/server/dist/library/scan.js +132 -0
  19. package/bundle/server/dist/library/scan.test.js +252 -0
  20. package/bundle/server/dist/library/service.js +87 -0
  21. package/bundle/server/dist/library/service.test.js +92 -0
  22. package/bundle/server/dist/library/test-images.js +23 -0
  23. package/bundle/server/dist/library/text-encoding.js +33 -0
  24. package/bundle/server/dist/library/text-encoding.test.js +42 -0
  25. package/bundle/server/dist/library/types.js +1 -0
  26. package/bundle/server/dist/library-static.js +75 -0
  27. package/bundle/server/dist/library-static.test.js +81 -0
  28. package/bundle/server/dist/listen-config.js +23 -0
  29. package/bundle/server/dist/listen-config.test.js +50 -0
  30. package/bundle/server/dist/reading/progress/merge.js +32 -0
  31. package/bundle/server/dist/reading/progress/schema.js +66 -0
  32. package/bundle/server/dist/reading/progress/schema.test.js +39 -0
  33. package/bundle/server/dist/reading/progress/service.js +81 -0
  34. package/bundle/server/dist/reading/progress/service.test.js +104 -0
  35. package/bundle/server/dist/reading/progress/storage.js +44 -0
  36. package/bundle/server/dist/reading/recent/schema.js +16 -0
  37. package/bundle/server/dist/reading/recent/service.js +54 -0
  38. package/bundle/server/dist/reading/recent/service.test.js +84 -0
  39. package/bundle/server/dist/reading/recent/storage.js +42 -0
  40. package/bundle/server/dist/reading/recent/storage.test.js +46 -0
  41. package/bundle/server/dist/routes/config.js +52 -0
  42. package/bundle/server/dist/routes/config.test.js +147 -0
  43. package/bundle/server/dist/routes/library.js +31 -0
  44. package/bundle/server/dist/routes/library.test.js +125 -0
  45. package/bundle/server/dist/routes/reading.js +66 -0
  46. package/bundle/server/dist/routes/reading.test.js +135 -0
  47. package/bundle/server/dist/web-static.js +31 -0
  48. package/bundle/server/dist/web-static.test.js +92 -0
  49. package/bundle/web/dist/assets/_shell-DPQLbvwD.js +5 -0
  50. package/bundle/web/dist/assets/_shell-gkEQU-gF.js +1 -0
  51. package/bundle/web/dist/assets/comic._id-BjwrciUv.js +1 -0
  52. package/bundle/web/dist/assets/dist-Da_WaNYN.js +1 -0
  53. package/bundle/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  54. package/bundle/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  55. package/bundle/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  56. package/bundle/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  57. package/bundle/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  58. package/bundle/web/dist/assets/image-BZKAkGUy.js +1 -0
  59. package/bundle/web/dist/assets/index-BpBtuR9k.css +2 -0
  60. package/bundle/web/dist/assets/index-CaXGEWDb.js +10 -0
  61. package/bundle/web/dist/assets/input-DcKYfXao.js +41 -0
  62. package/bundle/web/dist/assets/library-Dw1qyl0v.js +1 -0
  63. package/bundle/web/dist/assets/mutation-CwzbY9ri.js +1 -0
  64. package/bundle/web/dist/assets/novel._id-DsL8say-.js +4 -0
  65. package/bundle/web/dist/assets/query-keys-CDsPIOEO.js +1 -0
  66. package/bundle/web/dist/assets/reading-BseNE7P3.js +1 -0
  67. package/bundle/web/dist/assets/reading-progress-BcmbUd_d.js +1 -0
  68. package/bundle/web/dist/assets/save-reading-progress-AuKXiup6.js +1 -0
  69. package/bundle/web/dist/assets/scroll-area-Bzi-DiTy.js +1 -0
  70. package/bundle/web/dist/assets/settings-2-CCv0KiQI.js +1 -0
  71. package/bundle/web/dist/assets/settings-2wiLHSOF.js +1 -0
  72. package/bundle/web/dist/assets/shell-context-BT0BT8PF.js +1 -0
  73. package/bundle/web/dist/assets/toggle-group-DDKezi4R.js +1 -0
  74. package/bundle/web/dist/assets/useNavigate-BMyovDLu.js +1 -0
  75. package/bundle/web/dist/assets/utils-BmM72imW.js +1 -0
  76. package/bundle/web/dist/favicon.svg +1 -0
  77. package/bundle/web/dist/index.html +17 -0
  78. package/bundle/web/dist/novel-default-cover.png +0 -0
  79. package/dist/cli.js +40 -0
  80. package/dist/parse-options.js +30 -0
  81. package/dist/parse-options.test.js +26 -0
  82. package/dist/paths.js +11 -0
  83. package/dist/paths.test.js +13 -0
  84. package/dist/spawn-server.js +40 -0
  85. package/dist/spawn-server.test.js +46 -0
  86. package/package.json +32 -0
@@ -0,0 +1,23 @@
1
+ import { Hono } from 'hono';
2
+ import { mountLibraryStaticRoutes, refreshLibraryStaticRoutes } from './library-static.js';
3
+ import { mountWebStaticRoutes } from './web-static.js';
4
+ import { createConfigRoutes } from './routes/config.js';
5
+ import { createLibraryRoutes } from './routes/library.js';
6
+ import { createReadingRoutes } from './routes/reading.js';
7
+ export function createApp(configService, libraryService, recentService, progressService) {
8
+ const app = new Hono();
9
+ const api = new Hono();
10
+ const config = configService.get();
11
+ mountLibraryStaticRoutes(app, config);
12
+ const refreshStatic = () => refreshLibraryStaticRoutes(configService.get());
13
+ api.route('/config', createConfigRoutes(configService, libraryService, {
14
+ onConfigChange: () => {
15
+ refreshStatic();
16
+ },
17
+ }));
18
+ api.route('/library', createLibraryRoutes(libraryService, progressService));
19
+ api.route('/reading', createReadingRoutes(recentService, progressService));
20
+ app.route('/api', api);
21
+ mountWebStaticRoutes(app);
22
+ return app;
23
+ }
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import app from './app.js';
3
+ describe('app', () => {
4
+ it('GET / returns Hello Hono!', async () => {
5
+ const res = await app.request('/');
6
+ expect(res.status).toBe(200);
7
+ expect(await res.text()).toBe('Hello Hono!');
8
+ });
9
+ });
@@ -0,0 +1,7 @@
1
+ export function createDefaultConfig() {
2
+ return {
3
+ novelDirectories: [],
4
+ comicDirectories: [],
5
+ gbkNovelHandling: 'ignore',
6
+ };
7
+ }
@@ -0,0 +1,18 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ export function getConfigDir() {
4
+ const override = process.env.CYREADER_CONFIG_DIR;
5
+ if (override) {
6
+ return path.resolve(override);
7
+ }
8
+ return path.join(homedir(), '.cyreader');
9
+ }
10
+ export function getConfigFilePath() {
11
+ return path.join(getConfigDir(), 'config.json');
12
+ }
13
+ export function getRecentFilePath() {
14
+ return path.join(getConfigDir(), 'recent.json');
15
+ }
16
+ export function getProgressFilePath() {
17
+ return path.join(getConfigDir(), 'progress.json');
18
+ }
@@ -0,0 +1,84 @@
1
+ import path from 'node:path';
2
+ import { nanoid } from 'nanoid';
3
+ import { z } from 'zod';
4
+ const absoluteDirectoryPath = z
5
+ .string()
6
+ .min(1, 'Directory path cannot be empty')
7
+ .refine((value) => path.isAbsolute(value), {
8
+ message: 'Directory path must be absolute',
9
+ });
10
+ const directoryEntrySchema = z.preprocess((value) => {
11
+ if (typeof value === 'string') {
12
+ return { id: nanoid(), path: value };
13
+ }
14
+ if (value && typeof value === 'object' && 'path' in value) {
15
+ const entry = value;
16
+ const id = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : nanoid();
17
+ return {
18
+ id,
19
+ path: entry.path,
20
+ };
21
+ }
22
+ return value;
23
+ }, z.object({
24
+ id: z.string().min(1),
25
+ path: absoluteDirectoryPath,
26
+ }));
27
+ export const gbkNovelHandlingSchema = z.enum(['ignore', 'convert-to-utf-8']);
28
+ const cyreaderConfigObjectSchema = z.object({
29
+ novelDirectories: z.array(directoryEntrySchema),
30
+ comicDirectories: z.array(directoryEntrySchema),
31
+ gbkNovelHandling: gbkNovelHandlingSchema.default('ignore'),
32
+ });
33
+ export const cyreaderConfigSchema = cyreaderConfigObjectSchema.superRefine((config, ctx) => {
34
+ addUniquePathIssues(config.novelDirectories, 'novelDirectories', ctx);
35
+ addUniquePathIssues(config.comicDirectories, 'comicDirectories', ctx);
36
+ });
37
+ export const cyreaderConfigPatchSchema = cyreaderConfigObjectSchema
38
+ .partial()
39
+ .superRefine((config, ctx) => {
40
+ if (config.novelDirectories) {
41
+ addUniquePathIssues(config.novelDirectories, 'novelDirectories', ctx);
42
+ }
43
+ if (config.comicDirectories) {
44
+ addUniquePathIssues(config.comicDirectories, 'comicDirectories', ctx);
45
+ }
46
+ });
47
+ function addUniquePathIssues(directories, field, ctx) {
48
+ const seen = new Set();
49
+ for (const [index, directory] of directories.entries()) {
50
+ if (seen.has(directory.path)) {
51
+ ctx.addIssue({
52
+ code: 'custom',
53
+ message: 'Directory path must be unique',
54
+ path: [field, index, 'path'],
55
+ });
56
+ }
57
+ seen.add(directory.path);
58
+ }
59
+ }
60
+ export function createDirectoryEntry(pathValue) {
61
+ return {
62
+ id: nanoid(),
63
+ path: pathValue,
64
+ };
65
+ }
66
+ export function normalizeConfig(config) {
67
+ return {
68
+ novelDirectories: config.novelDirectories,
69
+ comicDirectories: config.comicDirectories,
70
+ gbkNovelHandling: config.gbkNovelHandling ?? 'ignore',
71
+ };
72
+ }
73
+ export function parseConfig(data) {
74
+ return normalizeConfig(cyreaderConfigSchema.parse(data));
75
+ }
76
+ export function formatZodError(error) {
77
+ return {
78
+ error: 'Validation failed',
79
+ details: error.issues.map((issue) => ({
80
+ path: issue.path.filter((segment) => typeof segment !== 'symbol'),
81
+ message: issue.message,
82
+ })),
83
+ };
84
+ }
@@ -0,0 +1,76 @@
1
+ import path from 'node:path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createDirectoryEntry, cyreaderConfigSchema, parseConfig } from './schema.js';
4
+ describe('cyreaderConfigSchema', () => {
5
+ it('accepts directory entries with absolute paths', () => {
6
+ const config = {
7
+ novelDirectories: [{ id: 'novel-1', path: '/home/user/novels' }],
8
+ comicDirectories: [{ id: 'comic-1', path: '/home/user/comics' }],
9
+ };
10
+ expect(parseConfig(config)).toEqual({ ...config, gbkNovelHandling: 'ignore' });
11
+ });
12
+ it('migrates legacy string directories to entries with ids', () => {
13
+ expect(parseConfig({
14
+ novelDirectories: ['/home/user/novels'],
15
+ comicDirectories: [],
16
+ })).toEqual({
17
+ novelDirectories: [
18
+ expect.objectContaining({ path: '/home/user/novels', id: expect.any(String) }),
19
+ ],
20
+ comicDirectories: [],
21
+ gbkNovelHandling: 'ignore',
22
+ });
23
+ });
24
+ it('assigns an id when a directory entry omits id', () => {
25
+ expect(parseConfig({
26
+ novelDirectories: [{ path: '/home/user/novels' }],
27
+ comicDirectories: [],
28
+ })).toEqual({
29
+ novelDirectories: [
30
+ expect.objectContaining({ path: '/home/user/novels', id: expect.any(String) }),
31
+ ],
32
+ comicDirectories: [],
33
+ gbkNovelHandling: 'ignore',
34
+ });
35
+ });
36
+ it('defaults gbkNovelHandling to ignore when omitted', () => {
37
+ expect(parseConfig({
38
+ novelDirectories: [],
39
+ comicDirectories: [],
40
+ }).gbkNovelHandling).toBe('ignore');
41
+ });
42
+ it('rejects relative directory paths', () => {
43
+ const cases = ['novels', './novels', '../novels', 'Books/Novels'];
44
+ for (const directory of cases) {
45
+ const result = cyreaderConfigSchema.safeParse({
46
+ novelDirectories: [createDirectoryEntry(directory)],
47
+ comicDirectories: [],
48
+ });
49
+ expect(result.success).toBe(false);
50
+ if (!result.success) {
51
+ expect(result.error.issues[0]?.message).toBe('Directory path must be absolute');
52
+ }
53
+ }
54
+ });
55
+ it('rejects empty directory paths', () => {
56
+ const result = cyreaderConfigSchema.safeParse({
57
+ novelDirectories: [{ id: 'novel-1', path: '' }],
58
+ comicDirectories: [],
59
+ });
60
+ expect(result.success).toBe(false);
61
+ });
62
+ it('rejects duplicate directory paths within the same list', () => {
63
+ const directory = path.resolve('/tmp/novels');
64
+ const result = cyreaderConfigSchema.safeParse({
65
+ novelDirectories: [
66
+ { id: 'novel-1', path: directory },
67
+ { id: 'novel-2', path: directory },
68
+ ],
69
+ comicDirectories: [],
70
+ });
71
+ expect(result.success).toBe(false);
72
+ if (!result.success) {
73
+ expect(result.error.issues[0]?.message).toBe('Directory path must be unique');
74
+ }
75
+ });
76
+ });
@@ -0,0 +1,38 @@
1
+ import { cyreaderConfigPatchSchema, cyreaderConfigSchema, normalizeConfig, parseConfig, } from './schema.js';
2
+ import { ensureConfig, ensureConfigDirectories, writeConfig } from './storage.js';
3
+ export class ConfigService {
4
+ config;
5
+ constructor(config) {
6
+ this.config = config;
7
+ }
8
+ static async create() {
9
+ const config = await ensureConfig();
10
+ return new ConfigService(config);
11
+ }
12
+ static fromConfig(config) {
13
+ return new ConfigService(normalizeConfig(cyreaderConfigSchema.parse(config)));
14
+ }
15
+ get() {
16
+ return this.config;
17
+ }
18
+ async replace(input) {
19
+ const normalized = parseConfig(input);
20
+ await ensureConfigDirectories(normalized);
21
+ await writeConfig(normalized);
22
+ this.config = normalized;
23
+ return normalized;
24
+ }
25
+ async patch(input) {
26
+ const parsed = cyreaderConfigPatchSchema.parse(input);
27
+ const merged = {
28
+ novelDirectories: parsed.novelDirectories ?? this.config.novelDirectories,
29
+ comicDirectories: parsed.comicDirectories ?? this.config.comicDirectories,
30
+ gbkNovelHandling: parsed.gbkNovelHandling ?? this.config.gbkNovelHandling,
31
+ };
32
+ const normalized = parseConfig(merged);
33
+ await ensureConfigDirectories(normalized);
34
+ await writeConfig(normalized);
35
+ this.config = normalized;
36
+ return normalized;
37
+ }
38
+ }
@@ -0,0 +1,54 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { createDefaultConfig } from './defaults.js';
3
+ import { getConfigDir, getConfigFilePath } from './paths.js';
4
+ import { parseConfig } from './schema.js';
5
+ async function fileExists(filePath) {
6
+ try {
7
+ await access(filePath);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ async function ensureDirectories(directories) {
15
+ await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true })));
16
+ }
17
+ export async function ensureConfigDirectories(config) {
18
+ await ensureDirectories([
19
+ ...config.novelDirectories.map((directory) => directory.path),
20
+ ...config.comicDirectories.map((directory) => directory.path),
21
+ ]);
22
+ }
23
+ export async function writeConfig(config) {
24
+ const configDir = getConfigDir();
25
+ const configFile = getConfigFilePath();
26
+ const normalized = parseConfig(config);
27
+ await mkdir(configDir, { recursive: true });
28
+ await writeFile(configFile, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
29
+ }
30
+ export async function readConfigFromDisk() {
31
+ const configFile = getConfigFilePath();
32
+ const content = await readFile(configFile, 'utf-8');
33
+ let data;
34
+ try {
35
+ data = JSON.parse(content);
36
+ }
37
+ catch (error) {
38
+ throw new Error(`Failed to parse config file: ${configFile}`, { cause: error });
39
+ }
40
+ return parseConfig(data);
41
+ }
42
+ export async function ensureConfig() {
43
+ const configDir = getConfigDir();
44
+ const configFile = getConfigFilePath();
45
+ await mkdir(configDir, { recursive: true });
46
+ if (!(await fileExists(configFile))) {
47
+ const defaultConfig = createDefaultConfig();
48
+ await writeConfig(defaultConfig);
49
+ return defaultConfig;
50
+ }
51
+ const config = await readConfigFromDisk();
52
+ await ensureConfigDirectories(config);
53
+ return config;
54
+ }
@@ -0,0 +1,79 @@
1
+ import { access, mkdtemp, readFile, 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 { getConfigFilePath } from './paths.js';
6
+ import { ensureConfig, readConfigFromDisk } from './storage.js';
7
+ describe('ensureConfig', () => {
8
+ let tempDir;
9
+ let previousConfigDir;
10
+ beforeEach(async () => {
11
+ previousConfigDir = process.env.CYREADER_CONFIG_DIR;
12
+ tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-config-'));
13
+ process.env.CYREADER_CONFIG_DIR = tempDir;
14
+ });
15
+ afterEach(async () => {
16
+ if (previousConfigDir === undefined) {
17
+ delete process.env.CYREADER_CONFIG_DIR;
18
+ }
19
+ else {
20
+ process.env.CYREADER_CONFIG_DIR = previousConfigDir;
21
+ }
22
+ await rm(tempDir, { recursive: true, force: true });
23
+ });
24
+ it('creates config directory and default config file when missing', async () => {
25
+ const config = await ensureConfig();
26
+ const configFile = getConfigFilePath();
27
+ expect(config).toEqual({
28
+ novelDirectories: [],
29
+ comicDirectories: [],
30
+ gbkNovelHandling: 'ignore',
31
+ });
32
+ await expect(access(configFile)).resolves.toBeUndefined();
33
+ const content = await readFile(configFile, 'utf-8');
34
+ expect(JSON.parse(content)).toEqual({
35
+ novelDirectories: [],
36
+ comicDirectories: [],
37
+ gbkNovelHandling: 'ignore',
38
+ });
39
+ });
40
+ it('reads an existing valid config', async () => {
41
+ const configFile = getConfigFilePath();
42
+ await writeFile(configFile, `${JSON.stringify({
43
+ novelDirectories: [{ id: 'novel-1', path: '/tmp/novels' }],
44
+ comicDirectories: [{ id: 'comic-1', path: '/tmp/comics' }],
45
+ })}\n`, 'utf-8');
46
+ const config = await ensureConfig();
47
+ expect(config).toEqual({
48
+ novelDirectories: [{ id: 'novel-1', path: '/tmp/novels' }],
49
+ comicDirectories: [{ id: 'comic-1', path: '/tmp/comics' }],
50
+ gbkNovelHandling: 'ignore',
51
+ });
52
+ });
53
+ it('migrates legacy string directories when reading config', async () => {
54
+ const configFile = getConfigFilePath();
55
+ await writeFile(configFile, `${JSON.stringify({
56
+ novelDirectories: ['/tmp/novels'],
57
+ comicDirectories: [],
58
+ })}\n`, 'utf-8');
59
+ const config = await ensureConfig();
60
+ expect(config).toEqual({
61
+ novelDirectories: [expect.objectContaining({ path: '/tmp/novels', id: expect.any(String) })],
62
+ comicDirectories: [],
63
+ gbkNovelHandling: 'ignore',
64
+ });
65
+ });
66
+ it('throws when config file contains invalid JSON', async () => {
67
+ const configFile = getConfigFilePath();
68
+ await writeFile(configFile, '{ invalid json', 'utf-8');
69
+ await expect(readConfigFromDisk()).rejects.toThrow('Failed to parse config file');
70
+ });
71
+ it('throws when config file contains non-absolute paths', async () => {
72
+ const configFile = getConfigFilePath();
73
+ await writeFile(configFile, `${JSON.stringify({
74
+ novelDirectories: [{ id: 'novel-1', path: 'relative/path' }],
75
+ comicDirectories: [],
76
+ })}\n`, 'utf-8');
77
+ await expect(readConfigFromDisk()).rejects.toThrow();
78
+ });
79
+ });
@@ -0,0 +1,28 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { createApp } from './app.js';
3
+ import { ConfigService } from './config/service.js';
4
+ import { LibraryService } from './library/service.js';
5
+ import { formatListenUrl, getListenHostname, getListenPort } from './listen-config.js';
6
+ import { ProgressService } from './reading/progress/service.js';
7
+ import { RecentService } from './reading/recent/service.js';
8
+ async function main() {
9
+ const configService = await ConfigService.create();
10
+ const libraryService = new LibraryService(() => configService.get());
11
+ const recentService = await RecentService.create(libraryService);
12
+ const progressService = await ProgressService.create(libraryService);
13
+ const app = createApp(configService, libraryService, recentService, progressService);
14
+ libraryService.tryScan();
15
+ const port = getListenPort();
16
+ const hostname = getListenHostname();
17
+ serve({
18
+ fetch: app.fetch,
19
+ port,
20
+ ...(hostname ? { hostname } : {}),
21
+ }, (info) => {
22
+ console.log(`Server is running on ${formatListenUrl(info.port, hostname)}`);
23
+ });
24
+ }
25
+ main().catch((error) => {
26
+ console.error(error);
27
+ process.exit(1);
28
+ });
@@ -0,0 +1,4 @@
1
+ const STATIC_PREFIX = '/static';
2
+ export function buildComicImageUrl(directoryId, title, filename) {
3
+ return `${STATIC_PREFIX}/${directoryId}/${encodeURIComponent(title)}/${encodeURIComponent(filename)}`;
4
+ }
@@ -0,0 +1,44 @@
1
+ import { access, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import sharp from 'sharp';
4
+ import { COMIC_COVER_FILENAME } from './files.js';
5
+ const COVER_SCALE = 3;
6
+ const JPEG_QUALITY = 75;
7
+ function comicCoverPath(comicDir) {
8
+ return path.join(comicDir, COMIC_COVER_FILENAME);
9
+ }
10
+ async function comicCoverExists(comicDir) {
11
+ try {
12
+ await access(comicCoverPath(comicDir));
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export async function resolveComicCoverFilename(comicDir) {
20
+ return (await comicCoverExists(comicDir)) ? COMIC_COVER_FILENAME : null;
21
+ }
22
+ export async function generateComicCoverIfMissing(comicDir, sourceImagePath) {
23
+ const coverPath = comicCoverPath(comicDir);
24
+ if (await comicCoverExists(comicDir)) {
25
+ return false;
26
+ }
27
+ try {
28
+ const metadata = await sharp(sourceImagePath).metadata();
29
+ const width = Math.max(1, Math.round((metadata.width ?? 1) / COVER_SCALE));
30
+ const height = Math.max(1, Math.round((metadata.height ?? 1) / COVER_SCALE));
31
+ const buffer = await sharp(sourceImagePath)
32
+ .resize(width, height)
33
+ .jpeg({ quality: JPEG_QUALITY })
34
+ .toBuffer();
35
+ const tempPath = `${coverPath}.cyreader-tmp`;
36
+ await writeFile(tempPath, buffer);
37
+ await rename(tempPath, coverPath);
38
+ return true;
39
+ }
40
+ catch (error) {
41
+ console.error(`Failed to generate comic cover: ${coverPath}`, error);
42
+ return false;
43
+ }
44
+ }
@@ -0,0 +1,12 @@
1
+ export class ScanInProgressError extends Error {
2
+ constructor() {
3
+ super('Scan already in progress');
4
+ this.name = 'ScanInProgressError';
5
+ }
6
+ }
7
+ export class NotFoundError extends Error {
8
+ constructor(message = 'Not found') {
9
+ super(message);
10
+ this.name = 'NotFoundError';
11
+ }
12
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
3
+ export const COMIC_COVER_FILENAME = 'cover.jpg';
4
+ const naturalCollator = new Intl.Collator(undefined, {
5
+ numeric: true,
6
+ sensitivity: 'base',
7
+ });
8
+ export function isHiddenName(name) {
9
+ return name.startsWith('.');
10
+ }
11
+ export function isTxtFile(name) {
12
+ return path.extname(name).toLowerCase() === '.txt';
13
+ }
14
+ export function isImageFile(name) {
15
+ return IMAGE_EXTENSIONS.has(path.extname(name).toLowerCase());
16
+ }
17
+ export function isComicCoverFile(name) {
18
+ return path.basename(name).toLowerCase() === COMIC_COVER_FILENAME;
19
+ }
20
+ export function compareByNaturalName(a, b) {
21
+ return naturalCollator.compare(path.basename(a), path.basename(b));
22
+ }
23
+ export function sortByNaturalName(paths) {
24
+ return paths.toSorted(compareByNaturalName);
25
+ }
26
+ const IMAGE_MIME_TYPES = {
27
+ '.jpg': 'image/jpeg',
28
+ '.jpeg': 'image/jpeg',
29
+ '.png': 'image/png',
30
+ '.webp': 'image/webp',
31
+ };
32
+ export function getImageMimeType(filePath) {
33
+ const ext = path.extname(filePath).toLowerCase();
34
+ return IMAGE_MIME_TYPES[ext] ?? 'application/octet-stream';
35
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isImageFile, isTxtFile, sortByNaturalName } from './files.js';
3
+ describe('isTxtFile', () => {
4
+ it('accepts .txt case-insensitively', () => {
5
+ expect(isTxtFile('book.txt')).toBe(true);
6
+ expect(isTxtFile('book.TXT')).toBe(true);
7
+ });
8
+ it('rejects non-txt extensions', () => {
9
+ expect(isTxtFile('book.pdf')).toBe(false);
10
+ });
11
+ });
12
+ describe('isImageFile', () => {
13
+ it('accepts jpg, jpeg, png, webp case-insensitively', () => {
14
+ expect(isImageFile('a.jpg')).toBe(true);
15
+ expect(isImageFile('a.JPEG')).toBe(true);
16
+ expect(isImageFile('a.png')).toBe(true);
17
+ expect(isImageFile('a.webp')).toBe(true);
18
+ });
19
+ it('rejects other extensions', () => {
20
+ expect(isImageFile('a.gif')).toBe(false);
21
+ expect(isImageFile('a.txt')).toBe(false);
22
+ });
23
+ });
24
+ describe('sortByNaturalName', () => {
25
+ it('sorts filenames naturally', () => {
26
+ const sorted = sortByNaturalName(['/comic/10.jpg', '/comic/2.jpg', '/comic/1.jpg']);
27
+ expect(sorted).toEqual(['/comic/1.jpg', '/comic/2.jpg', '/comic/10.jpg']);
28
+ });
29
+ });
@@ -0,0 +1,6 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ export function makeLocalItemId(absolutePath) {
4
+ const normalized = path.resolve(absolutePath);
5
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
6
+ }
@@ -0,0 +1,4 @@
1
+ const STATIC_PREFIX = '/static';
2
+ export function buildNovelContentUrl(directoryId, filename) {
3
+ return `${STATIC_PREFIX}/${directoryId}/${encodeURIComponent(filename)}`;
4
+ }