readshell 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/LICENSE +17 -0
- package/README.md +128 -0
- package/bin/readshell.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1656 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/parser.ts
|
|
4
|
+
import yargs from "yargs";
|
|
5
|
+
import { hideBin } from "yargs/helpers";
|
|
6
|
+
|
|
7
|
+
// src/services/BookService.ts
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
import { existsSync as existsSync2, statSync } from "fs";
|
|
10
|
+
import { nanoid } from "nanoid";
|
|
11
|
+
|
|
12
|
+
// src/db/client.ts
|
|
13
|
+
import Database from "better-sqlite3";
|
|
14
|
+
|
|
15
|
+
// src/config/paths.ts
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { mkdirSync, existsSync } from "fs";
|
|
19
|
+
function getAppDataDir() {
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
let configDir;
|
|
22
|
+
if (platform === "darwin") {
|
|
23
|
+
configDir = join(homedir(), "Library", "Application Support", "readshell");
|
|
24
|
+
} else if (platform === "win32") {
|
|
25
|
+
configDir = join(process.env["APPDATA"] || join(homedir(), "AppData", "Roaming"), "readshell");
|
|
26
|
+
} else {
|
|
27
|
+
configDir = join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "readshell");
|
|
28
|
+
}
|
|
29
|
+
if (!existsSync(configDir)) {
|
|
30
|
+
mkdirSync(configDir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
return configDir;
|
|
33
|
+
}
|
|
34
|
+
function getDbPath() {
|
|
35
|
+
return join(getAppDataDir(), "readshell.db");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/utils/logger.ts
|
|
39
|
+
var isDebug = process.env["DEBUG"] === "1" || process.env["DEBUG"] === "true";
|
|
40
|
+
var logger = {
|
|
41
|
+
debug: (...args) => {
|
|
42
|
+
if (isDebug) {
|
|
43
|
+
console.error("[DEBUG]", ...args);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
info: (...args) => {
|
|
47
|
+
console.error("[INFO]", ...args);
|
|
48
|
+
},
|
|
49
|
+
warn: (...args) => {
|
|
50
|
+
console.error("[WARN]", ...args);
|
|
51
|
+
},
|
|
52
|
+
error: (...args) => {
|
|
53
|
+
console.error("[ERROR]", ...args);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// src/db/client.ts
|
|
58
|
+
var db = null;
|
|
59
|
+
function getDb() {
|
|
60
|
+
if (!db) {
|
|
61
|
+
const dbPath = getDbPath();
|
|
62
|
+
logger.debug(`\u6570\u636E\u5E93\u8DEF\u5F84: ${dbPath}`);
|
|
63
|
+
db = new Database(dbPath);
|
|
64
|
+
db.pragma("journal_mode = WAL");
|
|
65
|
+
db.pragma("foreign_keys = ON");
|
|
66
|
+
}
|
|
67
|
+
return db;
|
|
68
|
+
}
|
|
69
|
+
function closeDb() {
|
|
70
|
+
if (db) {
|
|
71
|
+
db.close();
|
|
72
|
+
db = null;
|
|
73
|
+
logger.debug("\u6570\u636E\u5E93\u8FDE\u63A5\u5DF2\u5173\u95ED");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
process.on("exit", () => closeDb());
|
|
77
|
+
process.on("SIGINT", () => {
|
|
78
|
+
closeDb();
|
|
79
|
+
process.exit(0);
|
|
80
|
+
});
|
|
81
|
+
process.on("SIGTERM", () => {
|
|
82
|
+
closeDb();
|
|
83
|
+
process.exit(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// src/db/models/Book.ts
|
|
87
|
+
var BookModel = class {
|
|
88
|
+
/**
|
|
89
|
+
* 插入新书
|
|
90
|
+
*/
|
|
91
|
+
insert(book) {
|
|
92
|
+
const db2 = getDb();
|
|
93
|
+
db2.prepare(`
|
|
94
|
+
INSERT INTO books (id, title, author, file_path, format, file_hash, file_size, created_at)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
`).run(book.id, book.title, book.author, book.file_path, book.format, book.file_hash, book.file_size, book.created_at);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 通过 ID 获取书籍
|
|
100
|
+
*/
|
|
101
|
+
findById(id) {
|
|
102
|
+
const db2 = getDb();
|
|
103
|
+
return db2.prepare("SELECT * FROM books WHERE id = ?").get(id);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 通过文件 hash 查找(去重用)
|
|
107
|
+
*/
|
|
108
|
+
findByHash(hash) {
|
|
109
|
+
const db2 = getDb();
|
|
110
|
+
return db2.prepare("SELECT * FROM books WHERE file_hash = ?").get(hash);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 模糊搜索书名
|
|
114
|
+
*/
|
|
115
|
+
searchByTitle(keyword) {
|
|
116
|
+
const db2 = getDb();
|
|
117
|
+
return db2.prepare("SELECT * FROM books WHERE title LIKE ? ORDER BY created_at DESC").all(`%${keyword}%`);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 获取所有书籍
|
|
121
|
+
*/
|
|
122
|
+
findAll() {
|
|
123
|
+
const db2 = getDb();
|
|
124
|
+
return db2.prepare("SELECT * FROM books ORDER BY created_at DESC").all();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 删除书籍
|
|
128
|
+
*/
|
|
129
|
+
delete(id) {
|
|
130
|
+
const db2 = getDb();
|
|
131
|
+
db2.prepare("DELETE FROM books WHERE id = ?").run(id);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// src/db/models/Chapter.ts
|
|
136
|
+
var ChapterModel = class {
|
|
137
|
+
/**
|
|
138
|
+
* 批量插入章节索引
|
|
139
|
+
*/
|
|
140
|
+
insertMany(chapters) {
|
|
141
|
+
const db2 = getDb();
|
|
142
|
+
const stmt = db2.prepare(`
|
|
143
|
+
INSERT OR REPLACE INTO chapter_index (book_id, chapter_no, title, byte_offset)
|
|
144
|
+
VALUES (?, ?, ?, ?)
|
|
145
|
+
`);
|
|
146
|
+
db2.transaction(() => {
|
|
147
|
+
for (const chapter of chapters) {
|
|
148
|
+
stmt.run(chapter.book_id, chapter.chapter_no, chapter.title, chapter.byte_offset);
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 获取指定书籍的所有章节
|
|
154
|
+
*/
|
|
155
|
+
findByBookId(bookId) {
|
|
156
|
+
const db2 = getDb();
|
|
157
|
+
return db2.prepare("SELECT * FROM chapter_index WHERE book_id = ? ORDER BY chapter_no").all(bookId);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 获取指定章节
|
|
161
|
+
*/
|
|
162
|
+
findChapter(bookId, chapterNo) {
|
|
163
|
+
const db2 = getDb();
|
|
164
|
+
return db2.prepare("SELECT * FROM chapter_index WHERE book_id = ? AND chapter_no = ?").get(bookId, chapterNo);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 获取书籍章节总数
|
|
168
|
+
*/
|
|
169
|
+
getChapterCount(bookId) {
|
|
170
|
+
const db2 = getDb();
|
|
171
|
+
const result = db2.prepare("SELECT COUNT(*) as count FROM chapter_index WHERE book_id = ?").get(bookId);
|
|
172
|
+
return result.count;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 删除指定书籍的章节索引
|
|
176
|
+
*/
|
|
177
|
+
deleteByBookId(bookId) {
|
|
178
|
+
const db2 = getDb();
|
|
179
|
+
db2.prepare("DELETE FROM chapter_index WHERE book_id = ?").run(bookId);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// src/db/models/Recent.ts
|
|
184
|
+
var RecentModel = class {
|
|
185
|
+
/**
|
|
186
|
+
* 记录打开(插入或更新计数)
|
|
187
|
+
*/
|
|
188
|
+
recordOpen(bookId) {
|
|
189
|
+
const db2 = getDb();
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
db2.prepare(`
|
|
192
|
+
INSERT INTO recent_reads (book_id, opened_at, open_count)
|
|
193
|
+
VALUES (?, ?, 1)
|
|
194
|
+
ON CONFLICT(book_id) DO UPDATE SET
|
|
195
|
+
opened_at = excluded.opened_at,
|
|
196
|
+
open_count = open_count + 1
|
|
197
|
+
`).run(bookId, now);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 获取最近阅读列表(按时间倒序)
|
|
201
|
+
*/
|
|
202
|
+
getRecent(limit = 20) {
|
|
203
|
+
const db2 = getDb();
|
|
204
|
+
return db2.prepare("SELECT * FROM recent_reads ORDER BY opened_at DESC LIMIT ?").all(limit);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 删除记录
|
|
208
|
+
*/
|
|
209
|
+
delete(bookId) {
|
|
210
|
+
const db2 = getDb();
|
|
211
|
+
db2.prepare("DELETE FROM recent_reads WHERE book_id = ?").run(bookId);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// src/db/models/Progress.ts
|
|
216
|
+
var ProgressModel = class {
|
|
217
|
+
/**
|
|
218
|
+
* 保存或更新阅读进度
|
|
219
|
+
*/
|
|
220
|
+
upsert(progress) {
|
|
221
|
+
const db2 = getDb();
|
|
222
|
+
db2.prepare(`
|
|
223
|
+
INSERT INTO reading_progress (book_id, chapter_no, byte_offset, percent, updated_at, opened_at)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
225
|
+
ON CONFLICT(book_id) DO UPDATE SET
|
|
226
|
+
chapter_no = excluded.chapter_no,
|
|
227
|
+
byte_offset = excluded.byte_offset,
|
|
228
|
+
percent = excluded.percent,
|
|
229
|
+
updated_at = excluded.updated_at,
|
|
230
|
+
opened_at = excluded.opened_at
|
|
231
|
+
`).run(progress.book_id, progress.chapter_no, progress.byte_offset, progress.percent, progress.updated_at, progress.opened_at);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 获取指定书籍的阅读进度
|
|
235
|
+
*/
|
|
236
|
+
findByBookId(bookId) {
|
|
237
|
+
const db2 = getDb();
|
|
238
|
+
return db2.prepare("SELECT * FROM reading_progress WHERE book_id = ?").get(bookId);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* 获取最近打开的书籍进度(用于 resume)
|
|
242
|
+
*/
|
|
243
|
+
getLastOpened() {
|
|
244
|
+
const db2 = getDb();
|
|
245
|
+
return db2.prepare("SELECT * FROM reading_progress ORDER BY opened_at DESC LIMIT 1").get();
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 删除指定书籍的进度
|
|
249
|
+
*/
|
|
250
|
+
delete(bookId) {
|
|
251
|
+
const db2 = getDb();
|
|
252
|
+
db2.prepare("DELETE FROM reading_progress WHERE book_id = ?").run(bookId);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/parsers/TxtParser.ts
|
|
257
|
+
import { readFileSync } from "fs";
|
|
258
|
+
import { detect } from "chardet";
|
|
259
|
+
import iconv from "iconv-lite";
|
|
260
|
+
var CHAPTER_PATTERNS = [
|
|
261
|
+
/^第[零一二三四五六七八九十百千万\d]+[章节回卷集部篇]/m,
|
|
262
|
+
/^Chapter\s+\d+/im,
|
|
263
|
+
/^CHAPTER\s+\d+/m,
|
|
264
|
+
/^第\s*\d+\s*[章节回]/m
|
|
265
|
+
];
|
|
266
|
+
async function parseTxt(filePath) {
|
|
267
|
+
const buffer = readFileSync(filePath);
|
|
268
|
+
const encoding = detect(buffer) || "utf-8";
|
|
269
|
+
logger.debug(`\u68C0\u6D4B\u5230\u7F16\u7801: ${encoding}`);
|
|
270
|
+
const content = encoding.toLowerCase() === "utf-8" ? buffer.toString("utf-8") : iconv.decode(buffer, encoding);
|
|
271
|
+
const title = extractTitle(filePath);
|
|
272
|
+
const chapters = extractChapters(content);
|
|
273
|
+
return {
|
|
274
|
+
title,
|
|
275
|
+
author: null,
|
|
276
|
+
content,
|
|
277
|
+
chapters
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function extractTitle(filePath) {
|
|
281
|
+
const basename = filePath.split("/").pop() || filePath;
|
|
282
|
+
return basename.replace(/\.txt$/i, "").trim() || "\u672A\u547D\u540D";
|
|
283
|
+
}
|
|
284
|
+
function extractChapters(content) {
|
|
285
|
+
const chapters = [];
|
|
286
|
+
const lines = content.split("\n");
|
|
287
|
+
let byteOffset = 0;
|
|
288
|
+
for (const line of lines) {
|
|
289
|
+
const trimmed = line.trim();
|
|
290
|
+
for (const pattern of CHAPTER_PATTERNS) {
|
|
291
|
+
if (pattern.test(trimmed)) {
|
|
292
|
+
chapters.push({
|
|
293
|
+
title: trimmed,
|
|
294
|
+
byteOffset
|
|
295
|
+
});
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
byteOffset += Buffer.byteLength(line + "\n", "utf-8");
|
|
300
|
+
}
|
|
301
|
+
logger.debug(`\u68C0\u6D4B\u5230 ${chapters.length} \u4E2A\u7AE0\u8282`);
|
|
302
|
+
return chapters;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/parsers/EpubParser.ts
|
|
306
|
+
import { EPub } from "epub2";
|
|
307
|
+
import { convert } from "html-to-text";
|
|
308
|
+
async function parseEpub(filePath) {
|
|
309
|
+
logger.debug(`\u5F00\u59CB\u89E3\u6790 EPUB: ${filePath}`);
|
|
310
|
+
const epub = await openEpub(filePath);
|
|
311
|
+
const title = epub.metadata.title || "\u672A\u77E5\u4E66\u540D";
|
|
312
|
+
const author = epub.metadata.creator || null;
|
|
313
|
+
const chapters = [];
|
|
314
|
+
let fullContent = "";
|
|
315
|
+
let currentByteOffset = 0;
|
|
316
|
+
for (const chapterRef of epub.flow) {
|
|
317
|
+
if (!chapterRef.id) continue;
|
|
318
|
+
try {
|
|
319
|
+
const htmlText = await getChapterText(epub, chapterRef.id);
|
|
320
|
+
const plainText = convert(htmlText, {
|
|
321
|
+
wordwrap: false,
|
|
322
|
+
selectors: [
|
|
323
|
+
// 忽略图片和链接
|
|
324
|
+
{ selector: "img", format: "skip" },
|
|
325
|
+
{ selector: "a", options: { ignoreHref: true } }
|
|
326
|
+
]
|
|
327
|
+
});
|
|
328
|
+
if (!plainText.trim()) continue;
|
|
329
|
+
const chapterTitle = chapterRef.title || "\u65E0\u6807\u9898\u7AE0\u8282";
|
|
330
|
+
chapters.push({
|
|
331
|
+
title: chapterTitle,
|
|
332
|
+
byteOffset: currentByteOffset
|
|
333
|
+
});
|
|
334
|
+
const chapterContent = `
|
|
335
|
+
|
|
336
|
+
${chapterTitle}
|
|
337
|
+
|
|
338
|
+
${plainText}
|
|
339
|
+
`;
|
|
340
|
+
fullContent += chapterContent;
|
|
341
|
+
currentByteOffset += Buffer.byteLength(chapterContent, "utf-8");
|
|
342
|
+
} catch (err) {
|
|
343
|
+
logger.debug(`\u8B66\u544A: \u8BFB\u53D6\u7AE0\u8282 ${chapterRef.id} \u5931\u8D25`, err);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
title,
|
|
348
|
+
author,
|
|
349
|
+
content: fullContent,
|
|
350
|
+
chapters
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function openEpub(filePath) {
|
|
354
|
+
return new Promise((resolve2, reject) => {
|
|
355
|
+
const epub = new EPub(filePath);
|
|
356
|
+
epub.on("error", (err) => reject(err));
|
|
357
|
+
epub.on("end", () => resolve2(epub));
|
|
358
|
+
epub.parse();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function getChapterText(epub, chapterId) {
|
|
362
|
+
return new Promise((resolve2, reject) => {
|
|
363
|
+
epub.getChapter(chapterId, (err, text) => {
|
|
364
|
+
if (err) return reject(err);
|
|
365
|
+
resolve2(text);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/parsers/index.ts
|
|
371
|
+
async function parseFile(filePath, format) {
|
|
372
|
+
switch (format) {
|
|
373
|
+
case "txt":
|
|
374
|
+
return parseTxt(filePath);
|
|
375
|
+
case "epub":
|
|
376
|
+
return parseEpub(filePath);
|
|
377
|
+
default:
|
|
378
|
+
throw new Error(`\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u683C\u5F0F: ${format}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/utils/hash.ts
|
|
383
|
+
import { createHash } from "crypto";
|
|
384
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
385
|
+
async function computeFileHash(filePath) {
|
|
386
|
+
const buffer = readFileSync2(filePath);
|
|
387
|
+
const hash = createHash("sha256");
|
|
388
|
+
hash.update(buffer);
|
|
389
|
+
return hash.digest("hex");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/services/BookService.ts
|
|
393
|
+
var BookService = class {
|
|
394
|
+
bookModel = new BookModel();
|
|
395
|
+
chapterModel = new ChapterModel();
|
|
396
|
+
recentModel = new RecentModel();
|
|
397
|
+
progressModel = new ProgressModel();
|
|
398
|
+
/**
|
|
399
|
+
* 导入书籍文件
|
|
400
|
+
*/
|
|
401
|
+
async importBook(filePath) {
|
|
402
|
+
const absPath = resolve(filePath);
|
|
403
|
+
if (!existsSync2(absPath)) {
|
|
404
|
+
throw new Error(`\u6587\u4EF6\u4E0D\u5B58\u5728: ${absPath}`);
|
|
405
|
+
}
|
|
406
|
+
const format = this.detectFormat(absPath);
|
|
407
|
+
if (!format) {
|
|
408
|
+
throw new Error("\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u683C\u5F0F\u3002\u76EE\u524D\u652F\u6301: .txt, .epub");
|
|
409
|
+
}
|
|
410
|
+
const fileHash = await computeFileHash(absPath);
|
|
411
|
+
const existing = this.bookModel.findByHash(fileHash);
|
|
412
|
+
if (existing) {
|
|
413
|
+
logger.debug(`\u6587\u4EF6\u5DF2\u5B58\u5728: ${existing.title} (${existing.id})`);
|
|
414
|
+
return existing;
|
|
415
|
+
}
|
|
416
|
+
const parsed = await parseFile(absPath, format);
|
|
417
|
+
const stats = statSync(absPath);
|
|
418
|
+
const book = {
|
|
419
|
+
id: nanoid(),
|
|
420
|
+
title: parsed.title,
|
|
421
|
+
author: parsed.author || null,
|
|
422
|
+
file_path: absPath,
|
|
423
|
+
format,
|
|
424
|
+
file_hash: fileHash,
|
|
425
|
+
file_size: stats.size,
|
|
426
|
+
created_at: Date.now()
|
|
427
|
+
};
|
|
428
|
+
this.bookModel.insert(book);
|
|
429
|
+
if (parsed.chapters.length > 0) {
|
|
430
|
+
this.chapterModel.insertMany(
|
|
431
|
+
parsed.chapters.map((ch, idx) => ({
|
|
432
|
+
book_id: book.id,
|
|
433
|
+
chapter_no: idx,
|
|
434
|
+
title: ch.title,
|
|
435
|
+
byte_offset: ch.byteOffset
|
|
436
|
+
}))
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
logger.debug(`\u5BFC\u5165\u6210\u529F: ${book.title}`);
|
|
440
|
+
return book;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* 查找书籍(ID 或模糊匹配书名)
|
|
444
|
+
*/
|
|
445
|
+
findBook(target) {
|
|
446
|
+
const byId = this.bookModel.findById(target);
|
|
447
|
+
if (byId) return byId;
|
|
448
|
+
const results = this.bookModel.searchByTitle(target);
|
|
449
|
+
return results[0];
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 搜索书籍
|
|
453
|
+
*/
|
|
454
|
+
searchBooks(keyword) {
|
|
455
|
+
return this.bookModel.searchByTitle(keyword);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* 获取所有书籍
|
|
459
|
+
*/
|
|
460
|
+
getAllBooks() {
|
|
461
|
+
return this.bookModel.findAll();
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* 删除书籍及相关数据
|
|
465
|
+
*/
|
|
466
|
+
deleteBook(id) {
|
|
467
|
+
this.chapterModel.deleteByBookId(id);
|
|
468
|
+
this.progressModel.delete(id);
|
|
469
|
+
this.recentModel.delete(id);
|
|
470
|
+
this.bookModel.delete(id);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* 检测文件格式
|
|
474
|
+
*/
|
|
475
|
+
detectFormat(filePath) {
|
|
476
|
+
const ext = filePath.toLowerCase().split(".").pop();
|
|
477
|
+
if (ext === "txt") return "txt";
|
|
478
|
+
if (ext === "epub") return "epub";
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// src/cli/commands/import.ts
|
|
484
|
+
var importCommand = {
|
|
485
|
+
command: "import <file>",
|
|
486
|
+
describe: "\u5BFC\u5165\u672C\u5730\u6587\u4EF6\u5230\u4E66\u67B6",
|
|
487
|
+
builder: (yargs2) => {
|
|
488
|
+
return yargs2.positional("file", {
|
|
489
|
+
describe: "\u6587\u4EF6\u8DEF\u5F84\uFF08\u652F\u6301 .txt / .epub\uFF09",
|
|
490
|
+
type: "string",
|
|
491
|
+
demandOption: true
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
handler: async (argv) => {
|
|
495
|
+
try {
|
|
496
|
+
const bookService = new BookService();
|
|
497
|
+
const book = await bookService.importBook(argv.file);
|
|
498
|
+
console.log(`\u2713 \u5DF2\u5BFC\u5165: ${book.title} (${book.id})`);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
logger.error("\u5BFC\u5165\u5931\u8D25:", error);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// src/services/ProgressService.ts
|
|
507
|
+
var ProgressService = class {
|
|
508
|
+
progressModel = new ProgressModel();
|
|
509
|
+
recentModel = new RecentModel();
|
|
510
|
+
/**
|
|
511
|
+
* 保存阅读进度(退出时调用)
|
|
512
|
+
*/
|
|
513
|
+
saveProgress(bookId, chapterNo, byteOffset, percent) {
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
this.progressModel.upsert({
|
|
516
|
+
book_id: bookId,
|
|
517
|
+
chapter_no: chapterNo,
|
|
518
|
+
byte_offset: byteOffset,
|
|
519
|
+
percent,
|
|
520
|
+
updated_at: now,
|
|
521
|
+
opened_at: now
|
|
522
|
+
});
|
|
523
|
+
this.recentModel.recordOpen(bookId);
|
|
524
|
+
logger.debug(`\u8FDB\u5EA6\u5DF2\u4FDD\u5B58: book=${bookId}, chapter=${chapterNo}, offset=${byteOffset}, ${(percent * 100).toFixed(1)}%`);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* 获取指定书籍的阅读进度(resume 时调用)
|
|
528
|
+
*/
|
|
529
|
+
getProgress(bookId) {
|
|
530
|
+
return this.progressModel.findByBookId(bookId);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* 获取最近打开的书籍进度(启动时调用,决定 resume 哪本书)
|
|
534
|
+
*/
|
|
535
|
+
getLastOpenedBook() {
|
|
536
|
+
return this.progressModel.getLastOpened();
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// src/ui/renderApp.ts
|
|
541
|
+
import React6 from "react";
|
|
542
|
+
import { render } from "ink";
|
|
543
|
+
|
|
544
|
+
// src/ui/App.tsx
|
|
545
|
+
import { useState as useState6 } from "react";
|
|
546
|
+
import { Box as Box7, Text as Text7 } from "ink";
|
|
547
|
+
|
|
548
|
+
// src/ui/pages/ResumePage.tsx
|
|
549
|
+
import { useEffect, useState } from "react";
|
|
550
|
+
import { Box, Text, useApp } from "ink";
|
|
551
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
552
|
+
function ResumePage({ onNavigate }) {
|
|
553
|
+
const { exit } = useApp();
|
|
554
|
+
const [checking, setChecking] = useState(true);
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
const progressService = new ProgressService();
|
|
557
|
+
const lastProgress = progressService.getLastOpenedBook();
|
|
558
|
+
if (!lastProgress) {
|
|
559
|
+
setChecking(false);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const bookModel = new BookModel();
|
|
563
|
+
const book = bookModel.findById(lastProgress.book_id);
|
|
564
|
+
if (!book) {
|
|
565
|
+
setChecking(false);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
onNavigate("reader", book.id, lastProgress.byte_offset);
|
|
569
|
+
}, [onNavigate]);
|
|
570
|
+
if (checking) {
|
|
571
|
+
return /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u{1F4D6} \u68C0\u67E5\u9605\u8BFB\u8BB0\u5F55..." }) });
|
|
572
|
+
}
|
|
573
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
|
|
574
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "\u{1F4D6} ReadShell \u2014 \u7EC8\u7AEF\u5185\u8F7B\u9605\u8BFB" }),
|
|
575
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
576
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u6682\u65E0\u9605\u8BFB\u8BB0\u5F55\u3002" }),
|
|
577
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u4F7F\u7528 novel import <file> \u5BFC\u5165\u4E00\u672C\u4E66\u5F00\u59CB\u9605\u8BFB\u3002" }),
|
|
578
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u6309 q \u9000\u51FA" }) })
|
|
579
|
+
] })
|
|
580
|
+
] });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/ui/pages/LibraryPage.tsx
|
|
584
|
+
import { useState as useState2, useEffect as useEffect2 } from "react";
|
|
585
|
+
import { Box as Box2, Text as Text2, useApp as useApp2, useInput } from "ink";
|
|
586
|
+
|
|
587
|
+
// src/services/RecentService.ts
|
|
588
|
+
var RecentService = class {
|
|
589
|
+
recentModel = new RecentModel();
|
|
590
|
+
bookModel = new BookModel();
|
|
591
|
+
/**
|
|
592
|
+
* 获取最近阅读的书籍列表(包含书籍详情)
|
|
593
|
+
*/
|
|
594
|
+
getRecentBooks(limit = 20) {
|
|
595
|
+
const recentRecords = this.recentModel.getRecent(limit);
|
|
596
|
+
return recentRecords.map((record) => this.bookModel.findById(record.book_id)).filter((book) => book !== void 0);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* 记录打开事件
|
|
600
|
+
*/
|
|
601
|
+
recordOpen(bookId) {
|
|
602
|
+
this.recentModel.recordOpen(bookId);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// src/ui/pages/LibraryPage.tsx
|
|
607
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
608
|
+
function LibraryPage({ onNavigate }) {
|
|
609
|
+
const { exit } = useApp2();
|
|
610
|
+
const [books, setBooks] = useState2([]);
|
|
611
|
+
const [selectedIndex, setSelectedIndex] = useState2(0);
|
|
612
|
+
const [loading, setLoading] = useState2(true);
|
|
613
|
+
const isRawModeSupported = process.stdin.isTTY ?? false;
|
|
614
|
+
useEffect2(() => {
|
|
615
|
+
const recentService = new RecentService();
|
|
616
|
+
const recentBooks = recentService.getRecentBooks();
|
|
617
|
+
if (recentBooks.length > 0) {
|
|
618
|
+
setBooks(recentBooks);
|
|
619
|
+
} else {
|
|
620
|
+
const bookService = new BookService();
|
|
621
|
+
setBooks(bookService.getAllBooks());
|
|
622
|
+
}
|
|
623
|
+
setLoading(false);
|
|
624
|
+
}, []);
|
|
625
|
+
useInput((input, key) => {
|
|
626
|
+
if (input === "q") {
|
|
627
|
+
exit();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (books.length === 0) return;
|
|
631
|
+
if (key.upArrow || input === "k") {
|
|
632
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
633
|
+
}
|
|
634
|
+
if (key.downArrow || input === "j") {
|
|
635
|
+
setSelectedIndex((prev) => Math.min(prev + 1, books.length - 1));
|
|
636
|
+
}
|
|
637
|
+
if (key.backspace || key.delete || input === "d" || input === "x") {
|
|
638
|
+
const selected = books[selectedIndex];
|
|
639
|
+
if (selected) {
|
|
640
|
+
const bookService = new BookService();
|
|
641
|
+
bookService.deleteBook(selected.id);
|
|
642
|
+
setBooks((prev) => {
|
|
643
|
+
const next = prev.filter((b) => b.id !== selected.id);
|
|
644
|
+
if (selectedIndex >= next.length) {
|
|
645
|
+
setSelectedIndex(Math.max(0, next.length - 1));
|
|
646
|
+
}
|
|
647
|
+
return next;
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (key.return) {
|
|
653
|
+
const selected = books[selectedIndex];
|
|
654
|
+
if (selected) {
|
|
655
|
+
const progressService = new ProgressService();
|
|
656
|
+
const progress = progressService.getProgress(selected.id);
|
|
657
|
+
onNavigate("reader", selected.id, progress?.byte_offset ?? 0);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}, { isActive: isRawModeSupported });
|
|
661
|
+
if (loading) {
|
|
662
|
+
return /* @__PURE__ */ jsx2(Box2, { padding: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "\u{1F4DA} \u52A0\u8F7D\u4E66\u67B6..." }) });
|
|
663
|
+
}
|
|
664
|
+
if (books.length === 0) {
|
|
665
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", padding: 1, children: [
|
|
666
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: "\u{1F4DA} \u4E66\u67B6" }),
|
|
667
|
+
/* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
|
|
668
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u4E66\u67B6\u4E3A\u7A7A\u3002\u4F7F\u7528 novel import <file> \u5BFC\u5165\u4F60\u7684\u7B2C\u4E00\u672C\u4E66\u3002" }),
|
|
669
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u6309 q \u9000\u51FA" }) })
|
|
670
|
+
] })
|
|
671
|
+
] });
|
|
672
|
+
}
|
|
673
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", padding: 1, children: [
|
|
674
|
+
/* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
|
|
675
|
+
"\u{1F4DA} \u4E66\u67B6 (",
|
|
676
|
+
books.length,
|
|
677
|
+
" \u672C)"
|
|
678
|
+
] }),
|
|
679
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \u2191\u2193/jk \u9009\u62E9 \xB7 Enter \u6253\u5F00 \xB7 d/x \u5220\u9664 \xB7 q \u9000\u51FA" }),
|
|
680
|
+
/* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginTop: 1, children: books.map((book, index) => {
|
|
681
|
+
const isSelected = index === selectedIndex;
|
|
682
|
+
return /* @__PURE__ */ jsx2(Box2, { paddingX: 1, justifyContent: "space-between", children: /* @__PURE__ */ jsxs2(Box2, { children: [
|
|
683
|
+
/* @__PURE__ */ jsxs2(
|
|
684
|
+
Text2,
|
|
685
|
+
{
|
|
686
|
+
color: isSelected ? "cyan" : void 0,
|
|
687
|
+
bold: isSelected,
|
|
688
|
+
children: [
|
|
689
|
+
isSelected ? "\u25B8 " : " ",
|
|
690
|
+
book.title
|
|
691
|
+
]
|
|
692
|
+
}
|
|
693
|
+
),
|
|
694
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
695
|
+
" (",
|
|
696
|
+
book.format,
|
|
697
|
+
")"
|
|
698
|
+
] })
|
|
699
|
+
] }) }, book.id);
|
|
700
|
+
}) })
|
|
701
|
+
] });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/ui/pages/ReaderPage.tsx
|
|
705
|
+
import { useState as useState5, useEffect as useEffect4, useRef } from "react";
|
|
706
|
+
import { Box as Box6, Text as Text6, useApp as useApp3, useStdout } from "ink";
|
|
707
|
+
|
|
708
|
+
// src/ui/components/TextRenderer.tsx
|
|
709
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
710
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
711
|
+
function renderLineWithHighlight(line) {
|
|
712
|
+
if (!line) return /* @__PURE__ */ jsx3(Text3, { children: " " });
|
|
713
|
+
const regex = /(「.*?」|“.*?”|『.*?』|《.*?》)/g;
|
|
714
|
+
const parts = line.split(regex);
|
|
715
|
+
return /* @__PURE__ */ jsx3(Text3, { children: parts.map((part, index) => {
|
|
716
|
+
if (regex.test(part)) {
|
|
717
|
+
}
|
|
718
|
+
const isHighlight = index % 2 === 1;
|
|
719
|
+
if (isHighlight) {
|
|
720
|
+
return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: part }, index);
|
|
721
|
+
}
|
|
722
|
+
return /* @__PURE__ */ jsx3(Text3, { children: part }, index);
|
|
723
|
+
}) });
|
|
724
|
+
}
|
|
725
|
+
function TextRenderer({ lines, height }) {
|
|
726
|
+
const displayLines = [...lines];
|
|
727
|
+
if (height && displayLines.length < height) {
|
|
728
|
+
const padding = height - displayLines.length;
|
|
729
|
+
for (let i = 0; i < padding; i++) {
|
|
730
|
+
displayLines.push("");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: displayLines.map((line, index) => /* @__PURE__ */ jsx3(Box3, { children: renderLineWithHighlight(line) }, index)) });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/ui/components/StatusBar.tsx
|
|
737
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
738
|
+
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
739
|
+
function StatusBar({
|
|
740
|
+
bookTitle,
|
|
741
|
+
chapterTitle,
|
|
742
|
+
percent,
|
|
743
|
+
currentPage,
|
|
744
|
+
totalPages,
|
|
745
|
+
remainingTime
|
|
746
|
+
}) {
|
|
747
|
+
const displayPercent = (percent * 100).toFixed(1);
|
|
748
|
+
const titleDisplay = chapterTitle ? `${bookTitle} \xB7 ${chapterTitle}` : bookTitle;
|
|
749
|
+
return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", justifyContent: "space-between", borderStyle: "single", borderTop: false, borderLeft: false, borderRight: false, paddingX: 1, children: [
|
|
750
|
+
/* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs3(Text4, { color: "gray", children: [
|
|
751
|
+
"\u{1F4D6} ",
|
|
752
|
+
titleDisplay
|
|
753
|
+
] }) }),
|
|
754
|
+
/* @__PURE__ */ jsxs3(Box4, { children: [
|
|
755
|
+
remainingTime && /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
|
|
756
|
+
"\u9884\u8BA1\u5269\u4F59 ",
|
|
757
|
+
remainingTime,
|
|
758
|
+
" "
|
|
759
|
+
] }),
|
|
760
|
+
/* @__PURE__ */ jsxs3(Text4, { color: "gray", children: [
|
|
761
|
+
currentPage,
|
|
762
|
+
"/",
|
|
763
|
+
totalPages
|
|
764
|
+
] }),
|
|
765
|
+
/* @__PURE__ */ jsxs3(Text4, { color: "gray", children: [
|
|
766
|
+
displayPercent,
|
|
767
|
+
"%"
|
|
768
|
+
] }),
|
|
769
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "q\u9000\u51FA" })
|
|
770
|
+
] })
|
|
771
|
+
] });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/ui/components/ChapterNav.tsx
|
|
775
|
+
import { useState as useState3, useEffect as useEffect3 } from "react";
|
|
776
|
+
import { Box as Box5, Text as Text5, useInput as useInput2 } from "ink";
|
|
777
|
+
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
778
|
+
function ChapterNav({
|
|
779
|
+
chapters,
|
|
780
|
+
bookmarks,
|
|
781
|
+
currentChapterId,
|
|
782
|
+
termHeight,
|
|
783
|
+
onSelect,
|
|
784
|
+
onClose
|
|
785
|
+
}) {
|
|
786
|
+
const [activeTab, setActiveTab] = useState3("chapters");
|
|
787
|
+
const isBookmarks = activeTab === "bookmarks";
|
|
788
|
+
const currentList = isBookmarks ? bookmarks : chapters;
|
|
789
|
+
const initialIndex = currentChapterId && !isBookmarks ? Math.max(
|
|
790
|
+
0,
|
|
791
|
+
chapters.findIndex((c) => c.id === currentChapterId)
|
|
792
|
+
) : 0;
|
|
793
|
+
const [selectedIndex, setSelectedIndex] = useState3(initialIndex);
|
|
794
|
+
useEffect3(() => {
|
|
795
|
+
setSelectedIndex(isBookmarks ? 0 : initialIndex);
|
|
796
|
+
}, [activeTab, initialIndex, isBookmarks]);
|
|
797
|
+
const pageSize = Math.max(5, termHeight - 6);
|
|
798
|
+
const windowStart = Math.max(0, Math.floor(selectedIndex / pageSize) * pageSize);
|
|
799
|
+
const visibleItems = currentList.slice(windowStart, windowStart + pageSize);
|
|
800
|
+
const isRawModeSupported = process.stdin.isTTY ?? false;
|
|
801
|
+
useInput2(
|
|
802
|
+
(input, key) => {
|
|
803
|
+
if (key.escape || input === "q") {
|
|
804
|
+
onClose();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (key.tab) {
|
|
808
|
+
setActiveTab((prev) => prev === "chapters" ? "bookmarks" : "chapters");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (key.return) {
|
|
812
|
+
if (currentList[selectedIndex]) {
|
|
813
|
+
onSelect(currentList[selectedIndex].byte_offset);
|
|
814
|
+
}
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (key.upArrow || input === "k") {
|
|
818
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
819
|
+
}
|
|
820
|
+
if (key.downArrow || input === "j") {
|
|
821
|
+
setSelectedIndex((prev) => Math.min(currentList.length - 1, prev + 1));
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
{ isActive: isRawModeSupported }
|
|
825
|
+
);
|
|
826
|
+
return /* @__PURE__ */ jsxs4(
|
|
827
|
+
Box5,
|
|
828
|
+
{
|
|
829
|
+
flexDirection: "column",
|
|
830
|
+
borderStyle: "round",
|
|
831
|
+
borderColor: "green",
|
|
832
|
+
paddingX: 2,
|
|
833
|
+
paddingY: 1,
|
|
834
|
+
width: "80%",
|
|
835
|
+
alignSelf: "center",
|
|
836
|
+
marginTop: 2,
|
|
837
|
+
children: [
|
|
838
|
+
/* @__PURE__ */ jsxs4(Box5, { justifyContent: "space-between", marginBottom: 1, children: [
|
|
839
|
+
/* @__PURE__ */ jsxs4(Box5, { children: [
|
|
840
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, color: activeTab === "chapters" ? "green" : "gray", children: "[\u5168\u90E8\u7AE0\u8282] " }),
|
|
841
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, color: activeTab === "bookmarks" ? "green" : "gray", children: "[\u6211\u7684\u4E66\u7B7E]" })
|
|
842
|
+
] }),
|
|
843
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Enter \u8DF3\u8F6C \xB7 Tab \u5207\u6362 \xB7 Esc/q \u5173\u95ED" })
|
|
844
|
+
] }),
|
|
845
|
+
visibleItems.length === 0 ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u6CA1\u6709\u8BB0\u5F55" }) : visibleItems.map((item, idx) => {
|
|
846
|
+
const actualIndex = windowStart + idx;
|
|
847
|
+
const isSelected = actualIndex === selectedIndex;
|
|
848
|
+
return /* @__PURE__ */ jsxs4(
|
|
849
|
+
Text5,
|
|
850
|
+
{
|
|
851
|
+
color: isSelected ? "green" : void 0,
|
|
852
|
+
bold: isSelected,
|
|
853
|
+
children: [
|
|
854
|
+
isSelected ? "\u25B6 " : " ",
|
|
855
|
+
item.title
|
|
856
|
+
]
|
|
857
|
+
},
|
|
858
|
+
item.id
|
|
859
|
+
);
|
|
860
|
+
}),
|
|
861
|
+
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, justifyContent: "flex-end", children: /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
|
|
862
|
+
"\u7B2C ",
|
|
863
|
+
Math.floor(selectedIndex / pageSize) + 1,
|
|
864
|
+
" / ",
|
|
865
|
+
Math.ceil(currentList.length / pageSize) || 1,
|
|
866
|
+
" \u9875"
|
|
867
|
+
] }) })
|
|
868
|
+
]
|
|
869
|
+
}
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/ui/hooks/useReader.ts
|
|
874
|
+
import { useState as useState4, useCallback, useMemo } from "react";
|
|
875
|
+
function useReader(pages, initialByteOffset) {
|
|
876
|
+
const initialPage = useMemo(() => {
|
|
877
|
+
if (!initialByteOffset || pages.length === 0) return 0;
|
|
878
|
+
let targetPage = 0;
|
|
879
|
+
for (let i = 0; i < pages.length; i++) {
|
|
880
|
+
if (pages[i].byteOffset <= initialByteOffset) {
|
|
881
|
+
targetPage = i;
|
|
882
|
+
} else {
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return targetPage;
|
|
887
|
+
}, [pages, initialByteOffset]);
|
|
888
|
+
const [state, setState] = useState4({
|
|
889
|
+
currentPage: initialPage,
|
|
890
|
+
totalPages: pages.length
|
|
891
|
+
});
|
|
892
|
+
const nextPage = useCallback(() => {
|
|
893
|
+
setState((prev) => ({
|
|
894
|
+
...prev,
|
|
895
|
+
currentPage: Math.min(prev.currentPage + 1, prev.totalPages - 1)
|
|
896
|
+
}));
|
|
897
|
+
}, []);
|
|
898
|
+
const prevPage = useCallback(() => {
|
|
899
|
+
setState((prev) => ({
|
|
900
|
+
...prev,
|
|
901
|
+
currentPage: Math.max(prev.currentPage - 1, 0)
|
|
902
|
+
}));
|
|
903
|
+
}, []);
|
|
904
|
+
const goToPage = useCallback((pageNum) => {
|
|
905
|
+
setState((prev) => ({
|
|
906
|
+
...prev,
|
|
907
|
+
currentPage: Math.max(0, Math.min(pageNum, prev.totalPages - 1))
|
|
908
|
+
}));
|
|
909
|
+
}, []);
|
|
910
|
+
const goToOffset = useCallback((byteOffset) => {
|
|
911
|
+
let targetPage = 0;
|
|
912
|
+
for (let i = 0; i < pages.length; i++) {
|
|
913
|
+
if (pages[i].byteOffset <= byteOffset) {
|
|
914
|
+
targetPage = i;
|
|
915
|
+
} else {
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
setState((prev) => ({
|
|
920
|
+
...prev,
|
|
921
|
+
currentPage: targetPage
|
|
922
|
+
}));
|
|
923
|
+
}, [pages]);
|
|
924
|
+
const getCurrentPage = useCallback(() => {
|
|
925
|
+
return pages[state.currentPage];
|
|
926
|
+
}, [state.currentPage, pages]);
|
|
927
|
+
const getCurrentOffset = useCallback(() => {
|
|
928
|
+
return pages[state.currentPage]?.byteOffset ?? 0;
|
|
929
|
+
}, [state.currentPage, pages]);
|
|
930
|
+
const getPercent = useCallback(() => {
|
|
931
|
+
if (state.totalPages === 0) return 0;
|
|
932
|
+
return (state.currentPage + 1) / state.totalPages;
|
|
933
|
+
}, [state.currentPage, state.totalPages]);
|
|
934
|
+
const isFirstPage = state.currentPage === 0;
|
|
935
|
+
const isLastPage = state.currentPage === state.totalPages - 1;
|
|
936
|
+
return {
|
|
937
|
+
...state,
|
|
938
|
+
nextPage,
|
|
939
|
+
prevPage,
|
|
940
|
+
goToPage,
|
|
941
|
+
goToOffset,
|
|
942
|
+
getCurrentPage,
|
|
943
|
+
getCurrentOffset,
|
|
944
|
+
getPercent,
|
|
945
|
+
isFirstPage,
|
|
946
|
+
isLastPage
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/ui/hooks/useKeyboard.ts
|
|
951
|
+
import { useInput as useInput3 } from "ink";
|
|
952
|
+
function useKeyboard(handlers, isActive = true) {
|
|
953
|
+
const isRawModeSupported = process.stdin.isTTY ?? false;
|
|
954
|
+
const shouldListen = isRawModeSupported && isActive;
|
|
955
|
+
useInput3((input, key) => {
|
|
956
|
+
if (input === " " || input === "j" || key.downArrow || input === "f") {
|
|
957
|
+
handlers.onNext?.();
|
|
958
|
+
}
|
|
959
|
+
if (input === "k" || key.upArrow || input === "b") {
|
|
960
|
+
handlers.onPrev?.();
|
|
961
|
+
}
|
|
962
|
+
if (input === "q") {
|
|
963
|
+
handlers.onQuit?.();
|
|
964
|
+
}
|
|
965
|
+
if (input === "c") {
|
|
966
|
+
handlers.onChapterList?.();
|
|
967
|
+
}
|
|
968
|
+
if (input === "?") {
|
|
969
|
+
handlers.onHelp?.();
|
|
970
|
+
}
|
|
971
|
+
if (handlers.onBossKey && (key.escape || input === "esc" || input === "b" || input === "B")) {
|
|
972
|
+
handlers.onBossKey?.();
|
|
973
|
+
}
|
|
974
|
+
if (handlers.onBookmarkAdd && (input === "m" || input === "M")) {
|
|
975
|
+
handlers.onBookmarkAdd?.();
|
|
976
|
+
}
|
|
977
|
+
}, { isActive: shouldListen });
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/utils/stringWidth.ts
|
|
981
|
+
function getStringWidth(str) {
|
|
982
|
+
let width = 0;
|
|
983
|
+
for (const char of str) {
|
|
984
|
+
width += isFullWidth(char) ? 2 : 1;
|
|
985
|
+
}
|
|
986
|
+
return width;
|
|
987
|
+
}
|
|
988
|
+
function isFullWidth(char) {
|
|
989
|
+
const code = char.codePointAt(0);
|
|
990
|
+
if (code === void 0) return false;
|
|
991
|
+
return (
|
|
992
|
+
// CJK 统一表意字符
|
|
993
|
+
code >= 19968 && code <= 40959 || // CJK 统一表意字符扩展 A
|
|
994
|
+
code >= 13312 && code <= 19903 || // CJK 统一表意字符扩展 B
|
|
995
|
+
code >= 131072 && code <= 173791 || // CJK 兼容表意字符
|
|
996
|
+
code >= 63744 && code <= 64255 || // 全角 ASCII、全角标点
|
|
997
|
+
code >= 65281 && code <= 65376 || code >= 65504 && code <= 65510 || // CJK 标点符号
|
|
998
|
+
code >= 12288 && code <= 12351 || // 日文平假名/片假名
|
|
999
|
+
code >= 12352 && code <= 12543 || // 韩文音节
|
|
1000
|
+
code >= 44032 && code <= 55215
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/utils/paginate.ts
|
|
1005
|
+
function paginate(text, width, height) {
|
|
1006
|
+
const pages = [];
|
|
1007
|
+
const rawLines = text.split("\n");
|
|
1008
|
+
const wrappedLines = [];
|
|
1009
|
+
let currentOffset = 0;
|
|
1010
|
+
for (const rawLine of rawLines) {
|
|
1011
|
+
const wrapped = wrapLine(rawLine, width);
|
|
1012
|
+
for (const line of wrapped) {
|
|
1013
|
+
wrappedLines.push({ text: line, byteOffset: currentOffset });
|
|
1014
|
+
}
|
|
1015
|
+
currentOffset += Buffer.byteLength(rawLine + "\n", "utf-8");
|
|
1016
|
+
}
|
|
1017
|
+
for (let i = 0; i < wrappedLines.length; i += height) {
|
|
1018
|
+
const pageLines = wrappedLines.slice(i, i + height);
|
|
1019
|
+
pages.push({
|
|
1020
|
+
lines: pageLines.map((l) => l.text),
|
|
1021
|
+
byteOffset: pageLines[0]?.byteOffset ?? 0
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
return pages;
|
|
1025
|
+
}
|
|
1026
|
+
function wrapLine(line, width) {
|
|
1027
|
+
if (line.length === 0) return [""];
|
|
1028
|
+
const result = [];
|
|
1029
|
+
let currentLine = "";
|
|
1030
|
+
let currentWidth = 0;
|
|
1031
|
+
for (const char of line) {
|
|
1032
|
+
const charWidth = getStringWidth(char);
|
|
1033
|
+
if (currentWidth + charWidth > width) {
|
|
1034
|
+
result.push(currentLine);
|
|
1035
|
+
currentLine = char;
|
|
1036
|
+
currentWidth = charWidth;
|
|
1037
|
+
} else {
|
|
1038
|
+
currentLine += char;
|
|
1039
|
+
currentWidth += charWidth;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (currentLine.length > 0) {
|
|
1043
|
+
result.push(currentLine);
|
|
1044
|
+
}
|
|
1045
|
+
return result.length > 0 ? result : [""];
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/utils/encoding.ts
|
|
1049
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
1050
|
+
import { detect as detect2 } from "chardet";
|
|
1051
|
+
import iconv2 from "iconv-lite";
|
|
1052
|
+
function readFileWithEncoding(filePath) {
|
|
1053
|
+
const buffer = readFileSync3(filePath);
|
|
1054
|
+
const encoding = detect2(buffer) || "utf-8";
|
|
1055
|
+
if (encoding.toLowerCase() === "utf-8" || encoding.toLowerCase() === "ascii") {
|
|
1056
|
+
return buffer.toString("utf-8");
|
|
1057
|
+
}
|
|
1058
|
+
return iconv2.decode(buffer, encoding);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/services/ChapterService.ts
|
|
1062
|
+
var ChapterService = class {
|
|
1063
|
+
chapterModel = new ChapterModel();
|
|
1064
|
+
/**
|
|
1065
|
+
* 获取指定书籍的所有章节
|
|
1066
|
+
*/
|
|
1067
|
+
getChapters(bookId) {
|
|
1068
|
+
return this.chapterModel.findByBookId(bookId);
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* 获取指定章节信息
|
|
1072
|
+
*/
|
|
1073
|
+
getChapter(bookId, chapterNo) {
|
|
1074
|
+
return this.chapterModel.findChapter(bookId, chapterNo);
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* 获取章节总数
|
|
1078
|
+
*/
|
|
1079
|
+
getChapterCount(bookId) {
|
|
1080
|
+
return this.chapterModel.getChapterCount(bookId);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* 获取指定书籍下的所有章节
|
|
1084
|
+
*/
|
|
1085
|
+
getChaptersByBookId(bookId) {
|
|
1086
|
+
return this.chapterModel.findByBookId(bookId);
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* 根据 offset 查询当前所属章节(用于高亮当前所在章)
|
|
1090
|
+
*/
|
|
1091
|
+
getChapterByOffset(bookId, byteOffset) {
|
|
1092
|
+
const chapters = this.chapterModel.findByBookId(bookId);
|
|
1093
|
+
if (chapters.length === 0) return void 0;
|
|
1094
|
+
let current;
|
|
1095
|
+
for (const chapter of chapters) {
|
|
1096
|
+
if (chapter.byte_offset <= byteOffset) {
|
|
1097
|
+
current = chapter;
|
|
1098
|
+
} else {
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return current;
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
// src/db/models/Bookmark.ts
|
|
1107
|
+
var BookmarkModel = class {
|
|
1108
|
+
/**
|
|
1109
|
+
* 插入书签
|
|
1110
|
+
*/
|
|
1111
|
+
insert(bookmark) {
|
|
1112
|
+
const db2 = getDb();
|
|
1113
|
+
db2.prepare(`
|
|
1114
|
+
INSERT INTO bookmarks (book_id, title, byte_offset, created_at)
|
|
1115
|
+
VALUES (?, ?, ?, ?)
|
|
1116
|
+
`).run(bookmark.book_id, bookmark.title, bookmark.byte_offset, bookmark.created_at);
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* 获取指定书籍的所有书签
|
|
1120
|
+
*/
|
|
1121
|
+
findByBookId(bookId) {
|
|
1122
|
+
const db2 = getDb();
|
|
1123
|
+
return db2.prepare("SELECT * FROM bookmarks WHERE book_id = ? ORDER BY created_at DESC").all(bookId);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* 获取指定书签
|
|
1127
|
+
*/
|
|
1128
|
+
findById(id) {
|
|
1129
|
+
const db2 = getDb();
|
|
1130
|
+
return db2.prepare("SELECT * FROM bookmarks WHERE id = ?").get(id);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* 获取书籍书签总数
|
|
1134
|
+
*/
|
|
1135
|
+
getCount(bookId) {
|
|
1136
|
+
const db2 = getDb();
|
|
1137
|
+
const result = db2.prepare("SELECT COUNT(*) as count FROM bookmarks WHERE book_id = ?").get(bookId);
|
|
1138
|
+
return result.count;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* 删除书签
|
|
1142
|
+
*/
|
|
1143
|
+
delete(id) {
|
|
1144
|
+
const db2 = getDb();
|
|
1145
|
+
db2.prepare("DELETE FROM bookmarks WHERE id = ?").run(id);
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* 移除整本书的书签 (配合彻底清理书籍使用)
|
|
1149
|
+
*/
|
|
1150
|
+
deleteByBookId(bookId) {
|
|
1151
|
+
const db2 = getDb();
|
|
1152
|
+
db2.prepare("DELETE FROM bookmarks WHERE book_id = ?").run(bookId);
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
// src/services/BookmarkService.ts
|
|
1157
|
+
var BookmarkService = class {
|
|
1158
|
+
bookmarkModel;
|
|
1159
|
+
constructor() {
|
|
1160
|
+
this.bookmarkModel = new BookmarkModel();
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* 增加一条书签
|
|
1164
|
+
* @param title 该书签展现给用户的文案(一句话大纲)
|
|
1165
|
+
*/
|
|
1166
|
+
addBookmark(bookId, title, byteOffset) {
|
|
1167
|
+
this.bookmarkModel.insert({
|
|
1168
|
+
book_id: bookId,
|
|
1169
|
+
title,
|
|
1170
|
+
byte_offset: byteOffset,
|
|
1171
|
+
created_at: Date.now()
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* 罗列该书全部的书签
|
|
1176
|
+
*/
|
|
1177
|
+
getBookmarksByBookId(bookId) {
|
|
1178
|
+
return this.bookmarkModel.findByBookId(bookId);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* 删掉对应书签
|
|
1182
|
+
*/
|
|
1183
|
+
removeBookmark(id) {
|
|
1184
|
+
this.bookmarkModel.delete(id);
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
// src/utils/bossKey.ts
|
|
1189
|
+
function triggerBossKey() {
|
|
1190
|
+
console.clear();
|
|
1191
|
+
const fakeLog = `
|
|
1192
|
+
VITE v5.2.8 ready in 213 ms
|
|
1193
|
+
|
|
1194
|
+
\u279C Local: http://localhost:5173/
|
|
1195
|
+
\u279C Network: use --host to expose
|
|
1196
|
+
\u279C press h + enter to show help
|
|
1197
|
+
`;
|
|
1198
|
+
console.log(fakeLog);
|
|
1199
|
+
process.exit(0);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/utils/time.ts
|
|
1203
|
+
function estimateReadingTime(charCount, isChinese = true) {
|
|
1204
|
+
const charsPerMinute = isChinese ? 500 : 1250;
|
|
1205
|
+
return Math.ceil(charCount / charsPerMinute);
|
|
1206
|
+
}
|
|
1207
|
+
function formatReadingTime(minutes) {
|
|
1208
|
+
if (minutes < 60) {
|
|
1209
|
+
return `${minutes} \u5206\u949F`;
|
|
1210
|
+
}
|
|
1211
|
+
const hours = Math.floor(minutes / 60);
|
|
1212
|
+
const mins = minutes % 60;
|
|
1213
|
+
return mins > 0 ? `${hours} \u5C0F\u65F6 ${mins} \u5206\u949F` : `${hours} \u5C0F\u65F6`;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// src/ui/pages/ReaderPage.tsx
|
|
1217
|
+
import { Fragment, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
1218
|
+
function ReaderContent({
|
|
1219
|
+
book,
|
|
1220
|
+
bookId,
|
|
1221
|
+
pages,
|
|
1222
|
+
initialByteOffset,
|
|
1223
|
+
termHeight,
|
|
1224
|
+
contentHeight
|
|
1225
|
+
}) {
|
|
1226
|
+
const { exit } = useApp3();
|
|
1227
|
+
const [chapterTitle, setChapterTitle] = useState5();
|
|
1228
|
+
const [currentChapter, setCurrentChapter] = useState5();
|
|
1229
|
+
const [showChapterNav, setShowChapterNav] = useState5(false);
|
|
1230
|
+
const [allChapters, setAllChapters] = useState5([]);
|
|
1231
|
+
const [allBookmarks, setAllBookmarks] = useState5([]);
|
|
1232
|
+
const [toastMessage, setToastMessage] = useState5(null);
|
|
1233
|
+
const progressServiceRef = useRef(new ProgressService());
|
|
1234
|
+
const chapterServiceRef = useRef(new ChapterService());
|
|
1235
|
+
const bookmarkServiceRef = useRef(new BookmarkService());
|
|
1236
|
+
const reader = useReader(pages, initialByteOffset);
|
|
1237
|
+
useEffect4(() => {
|
|
1238
|
+
const currentOffset = reader.getCurrentOffset();
|
|
1239
|
+
const chapter = chapterServiceRef.current.getChapterByOffset(bookId, currentOffset);
|
|
1240
|
+
setCurrentChapter(chapter ?? void 0);
|
|
1241
|
+
setChapterTitle(chapter?.title ?? void 0);
|
|
1242
|
+
}, [reader.currentPage, bookId]);
|
|
1243
|
+
useEffect4(() => {
|
|
1244
|
+
const chaptersList = chapterServiceRef.current.getChaptersByBookId(bookId);
|
|
1245
|
+
setAllChapters(chaptersList);
|
|
1246
|
+
if (showChapterNav) {
|
|
1247
|
+
setAllBookmarks(bookmarkServiceRef.current.getBookmarksByBookId(bookId));
|
|
1248
|
+
}
|
|
1249
|
+
}, [bookId, showChapterNav]);
|
|
1250
|
+
const handleAddBookmark = () => {
|
|
1251
|
+
const currentPageInfo = reader.getCurrentPage();
|
|
1252
|
+
if (!currentPageInfo) return;
|
|
1253
|
+
let markTitle = "\u65E0\u6807\u9898\u4E66\u7B7E";
|
|
1254
|
+
for (const line of currentPageInfo.lines) {
|
|
1255
|
+
const stripped = line.trim();
|
|
1256
|
+
if (stripped.length > 0) {
|
|
1257
|
+
markTitle = stripped.slice(0, 15) + (stripped.length > 15 ? "..." : "");
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const currentOffset = reader.getCurrentOffset();
|
|
1262
|
+
bookmarkServiceRef.current.addBookmark(bookId, markTitle, currentOffset);
|
|
1263
|
+
setToastMessage(`\u2713 \u589E\u52A0\u4E66\u7B7E: ${markTitle}`);
|
|
1264
|
+
setTimeout(() => setToastMessage(null), 2e3);
|
|
1265
|
+
};
|
|
1266
|
+
useEffect4(() => {
|
|
1267
|
+
return () => {
|
|
1268
|
+
const offset = reader.getCurrentOffset();
|
|
1269
|
+
const percent = reader.getPercent();
|
|
1270
|
+
const chapter = chapterServiceRef.current.getChapterByOffset(bookId, offset);
|
|
1271
|
+
const chapterNo = chapter?.chapter_no ?? 0;
|
|
1272
|
+
progressServiceRef.current.saveProgress(bookId, chapterNo, offset, percent);
|
|
1273
|
+
logger.debug(`\u8FDB\u5EA6\u5DF2\u4FDD\u5B58: offset=${offset}, ${(percent * 100).toFixed(1)}%`);
|
|
1274
|
+
};
|
|
1275
|
+
}, [bookId, reader]);
|
|
1276
|
+
useKeyboard(
|
|
1277
|
+
{
|
|
1278
|
+
onNext: () => reader.nextPage(),
|
|
1279
|
+
onPrev: () => reader.prevPage(),
|
|
1280
|
+
onQuit: () => exit(),
|
|
1281
|
+
onChapterList: () => setShowChapterNav(true),
|
|
1282
|
+
onBossKey: () => triggerBossKey(),
|
|
1283
|
+
onBookmarkAdd: handleAddBookmark
|
|
1284
|
+
},
|
|
1285
|
+
!showChapterNav
|
|
1286
|
+
// 如果浮层显示,则停止普通的阅读快捷键
|
|
1287
|
+
);
|
|
1288
|
+
const currentPage = reader.getCurrentPage();
|
|
1289
|
+
const currentLines = currentPage?.lines ?? [];
|
|
1290
|
+
const calculatedContentHeight = Math.max(1, termHeight - 2);
|
|
1291
|
+
const totalChars = (book.file_size ?? 0) / 3;
|
|
1292
|
+
const remainingChars = Math.max(0, totalChars * (1 - reader.getPercent()));
|
|
1293
|
+
const remainingMinutes = estimateReadingTime(remainingChars, true);
|
|
1294
|
+
const remainingTimeStr = formatReadingTime(remainingMinutes);
|
|
1295
|
+
return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", height: termHeight, children: !showChapterNav ? /* @__PURE__ */ jsxs5(Fragment, { children: [
|
|
1296
|
+
/* @__PURE__ */ jsx6(Box6, { flexDirection: "column", flexGrow: 1, paddingX: 1, children: /* @__PURE__ */ jsx6(TextRenderer, { lines: currentLines, height: calculatedContentHeight }) }),
|
|
1297
|
+
/* @__PURE__ */ jsx6(
|
|
1298
|
+
StatusBar,
|
|
1299
|
+
{
|
|
1300
|
+
bookTitle: book.title,
|
|
1301
|
+
percent: reader.getPercent(),
|
|
1302
|
+
chapterTitle,
|
|
1303
|
+
currentPage: reader.currentPage + 1,
|
|
1304
|
+
totalPages: reader.totalPages,
|
|
1305
|
+
remainingTime: remainingTimeStr
|
|
1306
|
+
}
|
|
1307
|
+
),
|
|
1308
|
+
toastMessage && /* @__PURE__ */ jsx6(Box6, { alignSelf: "flex-end", marginTop: -2, marginRight: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: /* @__PURE__ */ jsx6(Text6, { color: "green", children: toastMessage }) })
|
|
1309
|
+
] }) : /* @__PURE__ */ jsx6(
|
|
1310
|
+
ChapterNav,
|
|
1311
|
+
{
|
|
1312
|
+
chapters: allChapters,
|
|
1313
|
+
bookmarks: allBookmarks,
|
|
1314
|
+
currentChapterId: currentChapter?.id,
|
|
1315
|
+
termHeight,
|
|
1316
|
+
onSelect: (offset) => {
|
|
1317
|
+
reader.goToOffset(offset);
|
|
1318
|
+
setShowChapterNav(false);
|
|
1319
|
+
},
|
|
1320
|
+
onClose: () => setShowChapterNav(false)
|
|
1321
|
+
}
|
|
1322
|
+
) });
|
|
1323
|
+
}
|
|
1324
|
+
function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
|
|
1325
|
+
const { exit } = useApp3();
|
|
1326
|
+
const { stdout } = useStdout();
|
|
1327
|
+
const [book, setBook] = useState5(null);
|
|
1328
|
+
const [pages, setPages] = useState5(null);
|
|
1329
|
+
const [error, setError] = useState5(null);
|
|
1330
|
+
const termWidth = stdout?.columns ?? 80;
|
|
1331
|
+
const termHeight = stdout?.rows ?? 24;
|
|
1332
|
+
const contentHeight = Math.max(termHeight - 3, 5);
|
|
1333
|
+
useEffect4(() => {
|
|
1334
|
+
try {
|
|
1335
|
+
const bookModel = new BookModel();
|
|
1336
|
+
const bookRecord = bookModel.findById(bookId);
|
|
1337
|
+
if (!bookRecord) {
|
|
1338
|
+
setError(`\u4E66\u7C4D\u4E0D\u5B58\u5728: ${bookId}`);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
setBook(bookRecord);
|
|
1342
|
+
const recentService = new RecentService();
|
|
1343
|
+
recentService.recordOpen(bookId);
|
|
1344
|
+
const content = readFileWithEncoding(bookRecord.file_path);
|
|
1345
|
+
const paginatedPages = paginate(content, termWidth - 2, contentHeight);
|
|
1346
|
+
setPages(paginatedPages);
|
|
1347
|
+
logger.debug(`\u52A0\u8F7D\u5B8C\u6210: ${bookRecord.title}, ${paginatedPages.length} \u9875`);
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
setError(`\u52A0\u8F7D\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`);
|
|
1350
|
+
}
|
|
1351
|
+
}, [bookId, termWidth, contentHeight]);
|
|
1352
|
+
useKeyboard({
|
|
1353
|
+
onQuit: () => exit()
|
|
1354
|
+
});
|
|
1355
|
+
if (error) {
|
|
1356
|
+
return /* @__PURE__ */ jsxs5(Box6, { padding: 1, flexDirection: "column", children: [
|
|
1357
|
+
/* @__PURE__ */ jsxs5(Text6, { color: "red", children: [
|
|
1358
|
+
"\u2717 ",
|
|
1359
|
+
error
|
|
1360
|
+
] }),
|
|
1361
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u6309 q \u9000\u51FA" })
|
|
1362
|
+
] });
|
|
1363
|
+
}
|
|
1364
|
+
if (!book || !pages) {
|
|
1365
|
+
return /* @__PURE__ */ jsx6(Box6, { padding: 1, children: /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "\u{1F4D6} \u6B63\u5728\u52A0\u8F7D..." }) });
|
|
1366
|
+
}
|
|
1367
|
+
return /* @__PURE__ */ jsx6(
|
|
1368
|
+
ReaderContent,
|
|
1369
|
+
{
|
|
1370
|
+
book,
|
|
1371
|
+
bookId,
|
|
1372
|
+
pages,
|
|
1373
|
+
initialByteOffset,
|
|
1374
|
+
termHeight,
|
|
1375
|
+
contentHeight
|
|
1376
|
+
}
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/ui/App.tsx
|
|
1381
|
+
import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1382
|
+
function App({ initialPage = "resume", bookId, initialByteOffset }) {
|
|
1383
|
+
const [currentPage, setCurrentPage] = useState6(initialPage);
|
|
1384
|
+
const [currentBookId, setCurrentBookId] = useState6(bookId);
|
|
1385
|
+
const [currentByteOffset, setCurrentByteOffset] = useState6(initialByteOffset);
|
|
1386
|
+
const navigateTo = (page, targetBookId, byteOffset) => {
|
|
1387
|
+
setCurrentPage(page);
|
|
1388
|
+
if (targetBookId) setCurrentBookId(targetBookId);
|
|
1389
|
+
if (byteOffset !== void 0) setCurrentByteOffset(byteOffset);
|
|
1390
|
+
};
|
|
1391
|
+
return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", width: "100%", children: [
|
|
1392
|
+
currentPage === "resume" && /* @__PURE__ */ jsx7(ResumePage, { onNavigate: navigateTo }),
|
|
1393
|
+
currentPage === "library" && /* @__PURE__ */ jsx7(LibraryPage, { onNavigate: navigateTo }),
|
|
1394
|
+
currentPage === "reader" && currentBookId && /* @__PURE__ */ jsx7(
|
|
1395
|
+
ReaderPage,
|
|
1396
|
+
{
|
|
1397
|
+
bookId: currentBookId,
|
|
1398
|
+
initialByteOffset: currentByteOffset,
|
|
1399
|
+
onNavigate: navigateTo
|
|
1400
|
+
}
|
|
1401
|
+
),
|
|
1402
|
+
currentPage === "reader" && !currentBookId && /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: "red", children: "\u9519\u8BEF: \u672A\u6307\u5B9A\u4E66\u7C4D" }) })
|
|
1403
|
+
] });
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// src/ui/renderApp.ts
|
|
1407
|
+
function renderApp(options = {}) {
|
|
1408
|
+
const { initialPage = "resume", bookId, initialByteOffset } = options;
|
|
1409
|
+
const { waitUntilExit } = render(
|
|
1410
|
+
React6.createElement(App, {
|
|
1411
|
+
initialPage,
|
|
1412
|
+
bookId,
|
|
1413
|
+
initialByteOffset
|
|
1414
|
+
})
|
|
1415
|
+
);
|
|
1416
|
+
waitUntilExit().catch(() => {
|
|
1417
|
+
process.exit(0);
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/cli/commands/resume.ts
|
|
1422
|
+
var resumeCommand = {
|
|
1423
|
+
command: "resume",
|
|
1424
|
+
describe: "\u6062\u590D\u4E0A\u6B21\u9605\u8BFB",
|
|
1425
|
+
handler: async () => {
|
|
1426
|
+
try {
|
|
1427
|
+
const progressService = new ProgressService();
|
|
1428
|
+
const lastProgress = progressService.getLastOpenedBook();
|
|
1429
|
+
if (!lastProgress) {
|
|
1430
|
+
console.log("\u{1F4DA} \u8FD8\u6CA1\u6709\u9605\u8BFB\u8BB0\u5F55\u3002\u4F7F\u7528 novel import <file> \u5BFC\u5165\u4E00\u672C\u4E66\u5F00\u59CB\u9605\u8BFB\u3002");
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
const bookService = new BookService();
|
|
1434
|
+
const book = bookService.findBook(lastProgress.book_id);
|
|
1435
|
+
if (!book) {
|
|
1436
|
+
console.log("\u{1F4DA} \u4E0A\u6B21\u9605\u8BFB\u7684\u4E66\u7C4D\u5DF2\u88AB\u5220\u9664\u3002\u4F7F\u7528 novel library \u67E5\u770B\u4E66\u67B6\u3002");
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
logger.debug(`\u6062\u590D\u9605\u8BFB: ${book.title}, offset=${lastProgress.byte_offset}`);
|
|
1440
|
+
renderApp({
|
|
1441
|
+
initialPage: "reader",
|
|
1442
|
+
bookId: lastProgress.book_id,
|
|
1443
|
+
initialByteOffset: lastProgress.byte_offset
|
|
1444
|
+
});
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
logger.error("\u6062\u590D\u9605\u8BFB\u5931\u8D25:", error);
|
|
1447
|
+
process.exit(1);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
// src/cli/commands/open.ts
|
|
1453
|
+
var openCommand = {
|
|
1454
|
+
command: "open <target>",
|
|
1455
|
+
describe: "\u6253\u5F00\u6307\u5B9A\u4E66\u7C4D\uFF08ID \u6216\u4E66\u540D\uFF09",
|
|
1456
|
+
builder: (yargs2) => {
|
|
1457
|
+
return yargs2.positional("target", {
|
|
1458
|
+
describe: "\u4E66\u7C4D ID \u6216\u4E66\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF09",
|
|
1459
|
+
type: "string",
|
|
1460
|
+
demandOption: true
|
|
1461
|
+
});
|
|
1462
|
+
},
|
|
1463
|
+
handler: async (argv) => {
|
|
1464
|
+
try {
|
|
1465
|
+
const bookService = new BookService();
|
|
1466
|
+
const book = bookService.findBook(argv.target);
|
|
1467
|
+
if (!book) {
|
|
1468
|
+
console.log(`\u2717 \u672A\u627E\u5230\u4E66\u7C4D: ${argv.target}`);
|
|
1469
|
+
console.log(" \u4F7F\u7528 novel library \u67E5\u770B\u4E66\u67B6\u4E2D\u7684\u6240\u6709\u4E66\u7C4D\u3002");
|
|
1470
|
+
process.exit(1);
|
|
1471
|
+
}
|
|
1472
|
+
const progressService = new ProgressService();
|
|
1473
|
+
const progress = progressService.getProgress(book.id);
|
|
1474
|
+
const byteOffset = progress?.byte_offset ?? 0;
|
|
1475
|
+
logger.debug(`\u6253\u5F00: ${book.title}, offset=${byteOffset}`);
|
|
1476
|
+
renderApp({
|
|
1477
|
+
initialPage: "reader",
|
|
1478
|
+
bookId: book.id,
|
|
1479
|
+
initialByteOffset: byteOffset
|
|
1480
|
+
});
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
logger.error("\u6253\u5F00\u5931\u8D25:", error);
|
|
1483
|
+
process.exit(1);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
// src/cli/commands/library.ts
|
|
1489
|
+
var libraryCommand = {
|
|
1490
|
+
command: "library",
|
|
1491
|
+
describe: "\u67E5\u770B\u4E66\u67B6\u5217\u8868",
|
|
1492
|
+
builder: (yargs2) => {
|
|
1493
|
+
return yargs2.option("search", {
|
|
1494
|
+
alias: "s",
|
|
1495
|
+
describe: "\u641C\u7D22\u4E66\u540D",
|
|
1496
|
+
type: "string"
|
|
1497
|
+
});
|
|
1498
|
+
},
|
|
1499
|
+
handler: async (argv) => {
|
|
1500
|
+
try {
|
|
1501
|
+
if (argv.search) {
|
|
1502
|
+
const bookService = new BookService();
|
|
1503
|
+
const books = bookService.searchBooks(argv.search);
|
|
1504
|
+
if (books.length === 0) {
|
|
1505
|
+
console.log(`\u{1F4DA} \u672A\u627E\u5230\u5339\u914D\u300C${argv.search}\u300D\u7684\u4E66\u7C4D\u3002`);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
console.log(`\u{1F4DA} \u641C\u7D22\u7ED3\u679C (${books.length} \u672C):
|
|
1509
|
+
`);
|
|
1510
|
+
books.forEach((book, index) => {
|
|
1511
|
+
console.log(` ${index + 1}. ${book.title} [${book.id}] (${book.format})`);
|
|
1512
|
+
});
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
renderApp({ initialPage: "library" });
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
logger.error("\u83B7\u53D6\u4E66\u67B6\u5931\u8D25:", error);
|
|
1518
|
+
process.exit(1);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
// src/cli/commands/remove.ts
|
|
1524
|
+
var removeCommand = {
|
|
1525
|
+
command: "remove <target>",
|
|
1526
|
+
describe: "\u4ECE\u4E66\u67B6\u79FB\u9664\u4E66\u7C4D\uFF08\u4EC5\u5220\u9664\u8BB0\u5F55\uFF0C\u4E0D\u5220\u6E90\u6587\u4EF6\uFF09",
|
|
1527
|
+
builder: (yargs2) => {
|
|
1528
|
+
return yargs2.positional("target", {
|
|
1529
|
+
describe: "\u4E66\u7C4D ID \u6216\u4E66\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF09",
|
|
1530
|
+
type: "string",
|
|
1531
|
+
demandOption: true
|
|
1532
|
+
});
|
|
1533
|
+
},
|
|
1534
|
+
handler: async (argv) => {
|
|
1535
|
+
try {
|
|
1536
|
+
const bookService = new BookService();
|
|
1537
|
+
const book = bookService.findBook(argv.target);
|
|
1538
|
+
if (!book) {
|
|
1539
|
+
console.log(`\u2717 \u672A\u627E\u5230\u5339\u914D\u4E66\u7C4D: ${argv.target}`);
|
|
1540
|
+
process.exit(1);
|
|
1541
|
+
}
|
|
1542
|
+
bookService.deleteBook(book.id);
|
|
1543
|
+
console.log(`\u2713 \u5DF2\u79FB\u9664\u4E66\u7C4D: ${book.title}`);
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
logger.error("\u79FB\u9664\u4E66\u7C4D\u5931\u8D25:", error);
|
|
1546
|
+
process.exit(1);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
// src/cli/parser.ts
|
|
1552
|
+
function createParser() {
|
|
1553
|
+
return yargs(hideBin(process.argv)).scriptName("novel").usage("$0 <command> [options]").command(importCommand).command(resumeCommand).command(openCommand).command(libraryCommand).command(removeCommand).demandCommand(1, "\u8BF7\u6307\u5B9A\u4E00\u4E2A\u547D\u4EE4\u3002\u4F7F\u7528 --help \u67E5\u770B\u53EF\u7528\u547D\u4EE4\u3002").strict().alias("h", "help").alias("v", "version").version("0.1.0").epilogue("ReadShell \u2014 \u7EC8\u7AEF\u5185\u4F4E\u6253\u65AD\u8F7B\u9605\u8BFB\u5DE5\u5177");
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/db/migrate.ts
|
|
1557
|
+
var SCHEMA_VERSION = 2;
|
|
1558
|
+
function initDatabase() {
|
|
1559
|
+
const db2 = getDb();
|
|
1560
|
+
db2.exec(`
|
|
1561
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
1562
|
+
version INTEGER PRIMARY KEY
|
|
1563
|
+
);
|
|
1564
|
+
`);
|
|
1565
|
+
const row = db2.prepare("SELECT version FROM schema_version LIMIT 1").get();
|
|
1566
|
+
const currentVersion = row?.version ?? 0;
|
|
1567
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
1568
|
+
logger.debug(`\u6570\u636E\u5E93\u8FC1\u79FB: v${currentVersion} \u2192 v${SCHEMA_VERSION}`);
|
|
1569
|
+
migrate(db2, currentVersion);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
function migrate(db2, fromVersion) {
|
|
1573
|
+
const migrations = {
|
|
1574
|
+
1: `
|
|
1575
|
+
-- \u4E66\u7C4D\u5143\u6570\u636E
|
|
1576
|
+
CREATE TABLE IF NOT EXISTS books (
|
|
1577
|
+
id TEXT PRIMARY KEY,
|
|
1578
|
+
title TEXT NOT NULL,
|
|
1579
|
+
author TEXT,
|
|
1580
|
+
file_path TEXT NOT NULL,
|
|
1581
|
+
format TEXT NOT NULL,
|
|
1582
|
+
file_hash TEXT NOT NULL,
|
|
1583
|
+
file_size INTEGER,
|
|
1584
|
+
created_at INTEGER NOT NULL
|
|
1585
|
+
);
|
|
1586
|
+
|
|
1587
|
+
-- \u6838\u5FC3\u72B6\u6001\u8868
|
|
1588
|
+
CREATE TABLE IF NOT EXISTS reading_progress (
|
|
1589
|
+
book_id TEXT PRIMARY KEY REFERENCES books(id),
|
|
1590
|
+
chapter_no INTEGER NOT NULL DEFAULT 0,
|
|
1591
|
+
byte_offset INTEGER NOT NULL DEFAULT 0,
|
|
1592
|
+
percent REAL NOT NULL DEFAULT 0,
|
|
1593
|
+
updated_at INTEGER NOT NULL,
|
|
1594
|
+
opened_at INTEGER NOT NULL
|
|
1595
|
+
);
|
|
1596
|
+
|
|
1597
|
+
-- \u6700\u8FD1\u9605\u8BFB\u6392\u5E8F
|
|
1598
|
+
CREATE TABLE IF NOT EXISTS recent_reads (
|
|
1599
|
+
book_id TEXT PRIMARY KEY REFERENCES books(id),
|
|
1600
|
+
opened_at INTEGER NOT NULL,
|
|
1601
|
+
open_count INTEGER NOT NULL DEFAULT 1
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
-- \u7AE0\u8282\u7D22\u5F15
|
|
1605
|
+
CREATE TABLE IF NOT EXISTS chapter_index (
|
|
1606
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1607
|
+
book_id TEXT NOT NULL REFERENCES books(id),
|
|
1608
|
+
chapter_no INTEGER NOT NULL,
|
|
1609
|
+
title TEXT,
|
|
1610
|
+
byte_offset INTEGER NOT NULL,
|
|
1611
|
+
UNIQUE(book_id, chapter_no)
|
|
1612
|
+
);
|
|
1613
|
+
|
|
1614
|
+
-- \u7D22\u5F15
|
|
1615
|
+
CREATE INDEX IF NOT EXISTS idx_chapter_book ON chapter_index(book_id);
|
|
1616
|
+
CREATE INDEX IF NOT EXISTS idx_recent_opened ON recent_reads(opened_at DESC);
|
|
1617
|
+
`,
|
|
1618
|
+
2: `
|
|
1619
|
+
-- \u4E66\u7B7E\u7BA1\u7406
|
|
1620
|
+
CREATE TABLE IF NOT EXISTS bookmarks (
|
|
1621
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1622
|
+
book_id TEXT NOT NULL REFERENCES books(id),
|
|
1623
|
+
title TEXT NOT NULL,
|
|
1624
|
+
byte_offset INTEGER NOT NULL,
|
|
1625
|
+
created_at INTEGER NOT NULL
|
|
1626
|
+
);
|
|
1627
|
+
|
|
1628
|
+
CREATE INDEX IF NOT EXISTS idx_bookmarks_book ON bookmarks(book_id);
|
|
1629
|
+
`
|
|
1630
|
+
};
|
|
1631
|
+
db2.transaction(() => {
|
|
1632
|
+
for (let v = fromVersion + 1; v <= SCHEMA_VERSION; v++) {
|
|
1633
|
+
const sql = migrations[v];
|
|
1634
|
+
if (sql) {
|
|
1635
|
+
db2.exec(sql);
|
|
1636
|
+
logger.debug(`\u5DF2\u6267\u884C\u8FC1\u79FB v${v}`);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
db2.prepare("DELETE FROM schema_version").run();
|
|
1640
|
+
db2.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION);
|
|
1641
|
+
})();
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// src/index.ts
|
|
1645
|
+
async function main() {
|
|
1646
|
+
try {
|
|
1647
|
+
initDatabase();
|
|
1648
|
+
const parser = createParser();
|
|
1649
|
+
await parser.parse();
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
logger.error("\u7A0B\u5E8F\u542F\u52A8\u5931\u8D25:", error);
|
|
1652
|
+
process.exit(1);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
main();
|
|
1656
|
+
//# sourceMappingURL=index.js.map
|