readshell-pro 0.3.6 → 0.4.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/dist/index.js DELETED
@@ -1,2374 +0,0 @@
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
- import { detect as detect2 } from "chardet";
309
- import iconv2 from "iconv-lite";
310
- async function parseEpub(filePath) {
311
- logger.debug(`\u5F00\u59CB\u89E3\u6790 EPUB: ${filePath}`);
312
- const epub = await openEpub(filePath);
313
- const title = epub.metadata.title || "\u672A\u77E5\u4E66\u540D";
314
- const author = epub.metadata.creator || null;
315
- const chapters = [];
316
- let fullContent = "";
317
- let currentByteOffset = 0;
318
- for (const chapterRef of epub.flow) {
319
- if (!chapterRef.id) continue;
320
- try {
321
- const htmlText = await getChapterHtml(epub, chapterRef.id);
322
- const plainText = convert(htmlText, {
323
- wordwrap: false,
324
- selectors: [
325
- // 忽略图片和链接
326
- { selector: "img", format: "skip" },
327
- { selector: "a", options: { ignoreHref: true } }
328
- ]
329
- });
330
- if (!plainText.trim()) continue;
331
- const chapterTitle = chapterRef.title || "\u65E0\u6807\u9898\u7AE0\u8282";
332
- chapters.push({
333
- title: chapterTitle,
334
- byteOffset: currentByteOffset
335
- });
336
- const chapterContent = `
337
-
338
- ${chapterTitle}
339
-
340
- ${plainText}
341
- `;
342
- fullContent += chapterContent;
343
- currentByteOffset += Buffer.byteLength(chapterContent, "utf-8");
344
- } catch (err) {
345
- logger.debug(`\u8B66\u544A: \u8BFB\u53D6\u7AE0\u8282 ${chapterRef.id} \u5931\u8D25`, err);
346
- }
347
- }
348
- return {
349
- title,
350
- author,
351
- content: fullContent,
352
- chapters
353
- };
354
- }
355
- function openEpub(filePath) {
356
- return new Promise((resolve3, reject) => {
357
- const epub = new EPub(filePath);
358
- epub.on("error", (err) => reject(err));
359
- epub.on("end", () => resolve3(epub));
360
- epub.parse();
361
- });
362
- }
363
- function detectHtmlEncoding(buffer) {
364
- const head = buffer.slice(0, 1024).toString("latin1");
365
- const xmlDecl = head.match(/<\?xml[^>]*encoding=["']([^"']+)["']/i);
366
- if (xmlDecl?.[1]) return xmlDecl[1];
367
- const metaCharset = head.match(/charset=["']?([A-Za-z0-9_-]+)["']?/i);
368
- if (metaCharset?.[1] && metaCharset[1].toLowerCase() !== "utf-8") {
369
- return metaCharset[1];
370
- }
371
- const chardetResult = detect2(buffer);
372
- return chardetResult || "utf-8";
373
- }
374
- function getChapterHtml(epub, chapterId) {
375
- return new Promise((resolve3, reject) => {
376
- epub.getFile(chapterId, (err, data) => {
377
- if (err) return reject(err);
378
- const buffer = data;
379
- const encoding = detectHtmlEncoding(buffer);
380
- const htmlText = encoding.toLowerCase().replace("-", "") === "utf8" || encoding.toLowerCase() === "utf-8" ? buffer.toString("utf-8") : iconv2.decode(buffer, encoding);
381
- resolve3(htmlText);
382
- });
383
- });
384
- }
385
-
386
- // src/parsers/index.ts
387
- async function parseFile(filePath, format) {
388
- switch (format) {
389
- case "txt":
390
- return parseTxt(filePath);
391
- case "epub":
392
- return parseEpub(filePath);
393
- default:
394
- throw new Error(`\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u683C\u5F0F: ${format}`);
395
- }
396
- }
397
-
398
- // src/utils/hash.ts
399
- import { createHash } from "crypto";
400
- import { readFileSync as readFileSync2 } from "fs";
401
- async function computeFileHash(filePath) {
402
- const buffer = readFileSync2(filePath);
403
- const hash = createHash("sha256");
404
- hash.update(buffer);
405
- return hash.digest("hex");
406
- }
407
-
408
- // src/services/BookService.ts
409
- var BookService = class {
410
- bookModel = new BookModel();
411
- chapterModel = new ChapterModel();
412
- recentModel = new RecentModel();
413
- progressModel = new ProgressModel();
414
- /**
415
- * 导入书籍文件
416
- */
417
- async importBook(filePath) {
418
- const absPath = resolve(filePath);
419
- if (!existsSync2(absPath)) {
420
- throw new Error(`\u6587\u4EF6\u4E0D\u5B58\u5728: ${absPath}`);
421
- }
422
- const format = this.detectFormat(absPath);
423
- if (!format) {
424
- throw new Error("\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u683C\u5F0F\u3002\u76EE\u524D\u652F\u6301: .txt, .epub");
425
- }
426
- const fileHash = await computeFileHash(absPath);
427
- const existing = this.bookModel.findByHash(fileHash);
428
- if (existing) {
429
- logger.debug(`\u6587\u4EF6\u5DF2\u5B58\u5728: ${existing.title} (${existing.id})`);
430
- return existing;
431
- }
432
- const parsed = await parseFile(absPath, format);
433
- const stats = statSync(absPath);
434
- const book = {
435
- id: nanoid(),
436
- title: parsed.title,
437
- author: parsed.author || null,
438
- file_path: absPath,
439
- format,
440
- file_hash: fileHash,
441
- file_size: stats.size,
442
- created_at: Date.now()
443
- };
444
- this.bookModel.insert(book);
445
- if (parsed.chapters.length > 0) {
446
- this.chapterModel.insertMany(
447
- parsed.chapters.map((ch, idx) => ({
448
- book_id: book.id,
449
- chapter_no: idx,
450
- title: ch.title,
451
- byte_offset: ch.byteOffset
452
- }))
453
- );
454
- }
455
- logger.debug(`\u5BFC\u5165\u6210\u529F: ${book.title}`);
456
- return book;
457
- }
458
- /**
459
- * 查找书籍(ID 或模糊匹配书名)
460
- */
461
- findBook(target) {
462
- const byId = this.bookModel.findById(target);
463
- if (byId) return byId;
464
- const results = this.bookModel.searchByTitle(target);
465
- return results[0];
466
- }
467
- /**
468
- * 搜索书籍
469
- */
470
- searchBooks(keyword) {
471
- return this.bookModel.searchByTitle(keyword);
472
- }
473
- /**
474
- * 获取所有书籍
475
- */
476
- getAllBooks() {
477
- return this.bookModel.findAll();
478
- }
479
- /**
480
- * 删除书籍及相关数据
481
- */
482
- deleteBook(id) {
483
- this.chapterModel.deleteByBookId(id);
484
- this.progressModel.delete(id);
485
- this.recentModel.delete(id);
486
- this.bookModel.delete(id);
487
- }
488
- /**
489
- * 检测文件格式
490
- */
491
- detectFormat(filePath) {
492
- const ext = filePath.toLowerCase().split(".").pop();
493
- if (ext === "txt") return "txt";
494
- if (ext === "epub") return "epub";
495
- return null;
496
- }
497
- };
498
-
499
- // src/locales/zh.ts
500
- var zh_default = {
501
- // Common
502
- "common.yes": "\u662F",
503
- "common.no": "\u5426",
504
- "common.confirm": "\u786E\u8BA4",
505
- "common.cancel": "\u53D6\u6D88",
506
- "common.quit": "\u6309 q \u9000\u51FA",
507
- // CLI
508
- "cli.import.desc": "\u5BFC\u5165\u672C\u5730\u6587\u4EF6\u6216\u76EE\u5F55\u5230\u4E66\u67B6",
509
- "cli.import.help": "\u6587\u4EF6\u6216\u76EE\u5F55\u8DEF\u5F84\uFF08\u652F\u6301 .txt / .epub\uFF09",
510
- "cli.import.success": "\u2713 \u5DF2\u5BFC\u5165:",
511
- "cli.import.fail": "\u5BFC\u5165\u5931\u8D25:",
512
- "cli.import.not_found": "\u8DEF\u5F84\u4E0D\u5B58\u5728:",
513
- "cli.import.unsupported": "\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u683C\u5F0F\u3002\u76EE\u524D\u652F\u6301: .txt, .epub",
514
- "cli.import.scan_dir": "\u626B\u63CF\u76EE\u5F55",
515
- "cli.import.found_files": "\u627E\u5230\u4EE5\u4E0B\u4E66\u7C4D\uFF1A",
516
- "cli.import.confirm_batch": "\u662F\u5426\u786E\u8BA4\u5BFC\u5165\u4E0A\u8FF0 {0} \u672C\u4E66\uFF1F(y/N)",
517
- "cli.import.canceled": "\u2713 \u5BFC\u5165\u5DF2\u53D6\u6D88",
518
- "cli.resume.desc": "\u6062\u590D\u4E0A\u6B21\u9605\u8BFB",
519
- "cli.resume.none": "\u2717 \u6CA1\u6709\u627E\u5230\u6700\u8FD1\u9605\u8BFB\u8BB0\u5F55\uFF0C\u8BF7\u5148\u4F7F\u7528 novel import <file> \u6216 novel open <book-id> \u6253\u5F00\u4E00\u672C\u4E66\u3002",
520
- "cli.open.desc": "\u6253\u5F00\u6307\u5B9A\u4E66\u7C4D",
521
- "cli.open.help": "\u4E66\u7C4D ID \u6216\u4E66\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF09",
522
- "cli.open.not_found": "\u2717 \u672A\u627E\u5230\u5339\u914D\u4E66\u7C4D:",
523
- "cli.library.desc": "\u67E5\u770B\u4E66\u67B6\u4E66\u7C4D\u5217\u8868",
524
- "cli.library.help": "\u53EF\u9009\u5173\u952E\u5B57\u641C\u7D22",
525
- "cli.library.none": "\u2717 \u4E66\u67B6\u4E3A\u7A7A\u3002\u4F7F\u7528 novel import <file> \u5BFC\u5165\u4F60\u7684\u7B2C\u4E00\u672C\u4E66\u3002",
526
- "cli.library.search_none": "\u{1F4DA} \u672A\u627E\u5230\u5339\u914D\u300C{0}\u300D\u7684\u4E66\u7C4D\u3002",
527
- "cli.library.search_result": "\u{1F4DA} \u641C\u7D22\u7ED3\u679C ({0} \u672C):\n",
528
- "cli.remove.desc": "\u4ECE\u4E66\u67B6\u79FB\u9664\u4E66\u7C4D\uFF08\u4EC5\u5220\u9664\u8BB0\u5F55\uFF0C\u4E0D\u5220\u6E90\u6587\u4EF6\uFF09",
529
- "cli.remove.help": "\u4E66\u7C4D ID \u6216\u4E66\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF09",
530
- "cli.remove.not_found": "\u2717 \u672A\u627E\u5230\u5339\u914D\u4E66\u7C4D:",
531
- "cli.remove.success": "\u2713 \u5DF2\u79FB\u9664\u4E66\u7C4D:",
532
- "cli.remove.fail": "\u79FB\u9664\u4E66\u7C4D\u5931\u8D25:",
533
- "cli.lang.desc": "\u5207\u6362\u754C\u9762\u8BED\u8A00",
534
- "cli.lang.help": "\u76EE\u6807\u8BED\u8A00\uFF08zh \u4E2D\u6587 | en \u82F1\u6587\uFF09",
535
- "cli.lang.success": "\u2713 \u8BED\u8A00\u5DF2\u5207\u6362\u4E3A: {0}",
536
- "cli.lang.unsupported": "\u2717 \u4E0D\u652F\u6301\u7684\u8BED\u8A00: {0}",
537
- "cli.config.desc": "\u4FEE\u6539\u5E94\u7528\u914D\u7F6E",
538
- "cli.config.line_spacing.desc": "\u884C\u95F4\u8DDD: 0 (\u7D27\u51D1) | 1 (\u9002\u4E2D) | 2 (\u5BBD\u677E)",
539
- "cli.config.line_spacing.success": "\u2713 \u884C\u95F4\u8DDD\u5DF2\u8BBE\u7F6E\u4E3A: {0}",
540
- "cli.config.reading_mode.desc": "\u9605\u8BFB\u6A21\u5F0F: page (\u7FFB\u9875) | scroll (\u6EDA\u52A8)",
541
- "cli.update.desc": "\u68C0\u67E5\u6700\u65B0\u7248\u672C\u5E76\u81EA\u52A8\u66F4\u65B0",
542
- "cli.update.checking": "\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...",
543
- "cli.update.latest": "\u2713 \u5F53\u524D\u5DF2\u662F\u6700\u65B0\u7248\u672C (v{0})",
544
- "cli.update.updating": "\u53D1\u73B0\u65B0\u7248\u672C v{0} (\u5F53\u524D v{1})\uFF0C\u6B63\u5728\u66F4\u65B0...",
545
- "cli.update.success": "\u2713 \u5347\u7EA7\u6210\u529F\uFF01\u8BF7\u91CD\u65B0\u8FD0\u884C novel \u547D\u4EE4\u3002",
546
- "cli.update.fail": "\u2717 \u5347\u7EA7\u5931\u8D25: {0}",
547
- // TUI - Library
548
- "tui.lib.loading": "\u{1F4DA} \u52A0\u8F7D\u4E66\u67B6...",
549
- "tui.lib.empty.title": "\u{1F4DA} \u4E66\u67B6",
550
- "tui.lib.empty.desc": "\u4E66\u67B6\u4E3A\u7A7A\u3002\u4F7F\u7528 novel import <file> \u5BFC\u5165\u4F60\u7684\u7B2C\u4E00\u672C\u4E66\u3002",
551
- "tui.lib.title": "\u{1F4DA} \u4E66\u67B6 ({0} \u672C)",
552
- "tui.lib.tips": " \u2191\u2193/jk \u9009\u62E9 \xB7 Enter \u6253\u5F00 \xB7 d/x \u5220\u9664 \xB7 q \u9000\u51FA",
553
- // TUI - Reader
554
- "tui.reader.loading": "\u8BFB\u53D6\u4E2D...",
555
- "tui.reader.bookmark_add": "\u2713 \u589E\u52A0\u4E66\u7B7E: {0}",
556
- "tui.reader.status.remaining": "\u9884\u8BA1\u5269\u4F59 {0}",
557
- "tui.nav.tab.chapters": "[\u5168\u90E8\u7AE0\u8282]",
558
- "tui.nav.tab.bookmarks": "[\u6211\u7684\u4E66\u7B7E]",
559
- "tui.nav.tips": "Enter \u8DF3\u8F6C \xB7 Tab \u5207\u6362 \xB7 Esc/q \u5173\u95ED",
560
- "tui.nav.hint": "\u6309 C \u952E\u5F39\u51FA\u6B64\u6E05\u5355\uFF0C\u6309 Tab \u5207\u6362\u4E66\u7B7E",
561
- "tui.nav.empty": "\u6CA1\u6709\u8BB0\u5F55",
562
- "tui.nav.page": "\u7B2C {0} / {1} \u9875",
563
- // Login / Logout / Sync
564
- "cli.login.already": "\u5DF2\u767B\u5F55\uFF08{0}\uFF09\uFF0C\u5982\u9700\u5207\u6362\u8BF7\u5148 novel logout",
565
- "cli.login.open_browser": "\u8BF7\u5728\u6D4F\u89C8\u5668\u6253\u5F00\uFF1A{0}",
566
- "cli.login.user_code": "\u6388\u6743\u7801\uFF1A{0}\uFF08\u5728 Dashboard \u786E\u8BA4\u65F6\u9700\u6838\u5BF9\u6B64\u7801\uFF09",
567
- "cli.login.waiting": "\u7B49\u5F85\u6388\u6743\u4E2D...\uFF085 \u5206\u949F\u8D85\u65F6\uFF09",
568
- "cli.login.success": "\u767B\u5F55\u6210\u529F\uFF01\u8D26\u6237\u5C42\u7EA7\uFF1A{0}",
569
- "cli.login.timeout": "\u6388\u6743\u8D85\u65F6\uFF0C\u8BF7\u91CD\u65B0\u8FD0\u884C novel login",
570
- "cli.login.migrate_hint": "\u63D0\u793A\uFF1A\u5982\u4ECE\u5F00\u6E90\u7248\u8FC1\u79FB\uFF0C\u8BF7\u8FD0\u884C\uFF1Anpm uninstall -g readshell && npm install -g readshell-pro",
571
- "cli.logout.success": "\u5DF2\u9000\u51FA\u767B\u5F55\uFF0C\u5207\u6362\u4E3A\u672C\u5730\u6A21\u5F0F",
572
- "cli.sync.not_logged_in": "\u8BF7\u5148\u8FD0\u884C novel login",
573
- "cli.sync.need_pro": "\u4E91\u540C\u6B65\u9700\u8981 Pro \u8BA2\u9605\uFF0C\u8BF7\u8BBF\u95EE app.readshell.com \u5347\u7EA7",
574
- "cli.sync.running": "\u540C\u6B65\u4E2D...",
575
- "cli.sync.success": "\u540C\u6B65\u5B8C\u6210\uFF08\u4E0A\u4F20 {0} \u6761\uFF0C\u4E0B\u8F7D {1} \u6761\uFF09",
576
- "cli.sync.failed": "\u540C\u6B65\u5931\u8D25\uFF1A{0}"
577
- };
578
-
579
- // src/locales/en.ts
580
- var en = {
581
- // Common
582
- "common.yes": "Yes",
583
- "common.no": "No",
584
- "common.confirm": "Confirm",
585
- "common.cancel": "Cancel",
586
- "common.quit": "Press q to quit",
587
- // CLI
588
- "cli.import.desc": "Import local file or directory to library",
589
- "cli.import.help": "File or directory path (supports .txt / .epub)",
590
- "cli.import.success": "\u2713 Imported:",
591
- "cli.import.fail": "Import failed:",
592
- "cli.import.not_found": "Path not found:",
593
- "cli.import.unsupported": "Unsupported file format. Currently supports: .txt, .epub",
594
- "cli.import.scan_dir": "Scanning directory",
595
- "cli.import.found_files": "Found following books:",
596
- "cli.import.confirm_batch": "Confirm importing these {0} books? (y/N)",
597
- "cli.import.canceled": "\u2713 Import canceled",
598
- "cli.resume.desc": "Resume last reading",
599
- "cli.resume.none": "\u2717 No recent reading record found. Please use `novel import <file>` or `novel open <book-id>` first.",
600
- "cli.open.desc": "Open specific book",
601
- "cli.open.help": "Book ID or title (fuzzy match)",
602
- "cli.open.not_found": "\u2717 Book not found:",
603
- "cli.library.desc": "View book list / library",
604
- "cli.library.help": "Optional keyword search",
605
- "cli.library.none": "\u2717 Library is empty. Use `novel import <file>` to import your first book.",
606
- "cli.library.search_none": '\u{1F4DA} No books found matching "{0}".',
607
- "cli.library.search_result": "\u{1F4DA} Search results ({0} books):\n",
608
- "cli.remove.desc": "Remove book from library (only deletes records, not source file)",
609
- "cli.remove.help": "Book ID or title (fuzzy match)",
610
- "cli.remove.not_found": "\u2717 Book not found:",
611
- "cli.remove.success": "\u2713 Book removed:",
612
- "cli.remove.fail": "Failed to remove book:",
613
- "cli.lang.desc": "Switch interface language",
614
- "cli.lang.help": "Target language (zh Chinese | en English)",
615
- "cli.lang.success": "\u2713 Language switched to: {0}",
616
- "cli.lang.unsupported": "\u2717 Unsupported language: {0}",
617
- "cli.config.desc": "Modify application configuration",
618
- "cli.config.line_spacing.desc": "Line spacing: 0 (tight) | 1 (medium) | 2 (loose)",
619
- "cli.config.line_spacing.success": "\u2713 Line spacing set to: {0}",
620
- "cli.config.reading_mode.desc": "Reading mode: page (paging) | scroll (scrolling)",
621
- "cli.update.desc": "Check for the latest version and update automatically",
622
- "cli.update.checking": "Checking for updates...",
623
- "cli.update.latest": "\u2713 Already up to date (v{0})",
624
- "cli.update.updating": "New version v{0} found (current v{1}), updating...",
625
- "cli.update.success": "\u2713 Successfully updated! Please restart novel command.",
626
- "cli.update.fail": "\u2717 Update failed: {0}",
627
- // TUI - Library
628
- "tui.lib.loading": "\u{1F4DA} Loading library...",
629
- "tui.lib.empty.title": "\u{1F4DA} Library",
630
- "tui.lib.empty.desc": "Library is empty. Use `novel import <file>` to import your first book.",
631
- "tui.lib.title": "\u{1F4DA} Library ({0} books)",
632
- "tui.lib.tips": " \u2191\u2193/jk select \xB7 Enter open \xB7 d/x delete \xB7 q quit",
633
- // TUI - Reader
634
- "tui.reader.loading": "Loading...",
635
- "tui.reader.bookmark_add": "\u2713 Bookmark added: {0}",
636
- "tui.reader.status.remaining": "Est. remaining {0}",
637
- // TUI - ChapterNav
638
- "tui.nav.tab.chapters": "[All Chapters]",
639
- "tui.nav.tab.bookmarks": "[My Bookmarks]",
640
- "tui.nav.tips": "Enter jump \xB7 Tab switch \xB7 Esc/q close",
641
- "tui.nav.hint": "Press C to open this list, press Tab to switch to bookmarks",
642
- "tui.nav.empty": "No records",
643
- "tui.nav.page": "Page {0} / {1}",
644
- // Login / Logout / Sync
645
- "cli.login.already": "Already logged in ({0}). Run novel logout to switch accounts.",
646
- "cli.login.open_browser": "Open in browser: {0}",
647
- "cli.login.user_code": "Authorization code: {0} (verify this in the Dashboard)",
648
- "cli.login.waiting": "Waiting for authorization... (5-minute timeout)",
649
- "cli.login.success": "Login successful! Account tier: {0}",
650
- "cli.login.timeout": "Authorization timed out. Please run novel login again.",
651
- "cli.login.migrate_hint": "Tip: migrating from open-source? Run: npm uninstall -g readshell && npm install -g readshell-pro",
652
- "cli.logout.success": "Logged out. Switched to local-only mode.",
653
- "cli.sync.not_logged_in": "Please run novel login first.",
654
- "cli.sync.need_pro": "Cloud sync requires a Pro subscription. Visit app.readshell.com to upgrade.",
655
- "cli.sync.running": "Syncing...",
656
- "cli.sync.success": "Sync complete (uploaded {0}, downloaded {1})",
657
- "cli.sync.failed": "Sync failed: {0}"
658
- };
659
- var en_default = en;
660
-
661
- // src/config/AppConfig.ts
662
- import Conf from "conf";
663
- var defaults = {
664
- linesPerPage: 0,
665
- showStatusBar: true,
666
- readingMode: "page",
667
- language: "zh",
668
- lineSpacing: 0,
669
- bossKeyLang: "nodejs"
670
- };
671
- var config = new Conf({
672
- projectName: "readshell",
673
- defaults
674
- });
675
- function getConfig() {
676
- return {
677
- linesPerPage: config.get("linesPerPage"),
678
- showStatusBar: config.get("showStatusBar"),
679
- readingMode: config.get("readingMode"),
680
- language: config.get("language"),
681
- lineSpacing: config.get("lineSpacing"),
682
- bossKeyLang: config.get("bossKeyLang"),
683
- authToken: config.get("authToken"),
684
- authUserId: config.get("authUserId"),
685
- authDeviceId: config.get("authDeviceId"),
686
- authTier: config.get("authTier"),
687
- authExpiresAt: config.get("authExpiresAt")
688
- };
689
- }
690
- function setConfig(key, value) {
691
- config.set(key, value);
692
- }
693
- function isLoggedIn() {
694
- const token = config.get("authToken");
695
- const expiresAt = config.get("authExpiresAt");
696
- if (!token) return false;
697
- if (!expiresAt) return false;
698
- return new Date(expiresAt) > /* @__PURE__ */ new Date();
699
- }
700
- function getAuthToken() {
701
- return config.get("authToken") ?? null;
702
- }
703
- function getAuthDeviceId() {
704
- return config.get("authDeviceId") ?? null;
705
- }
706
- function getAuthTier() {
707
- return config.get("authTier") ?? null;
708
- }
709
- function clearAuth() {
710
- config.delete("authToken");
711
- config.delete("authUserId");
712
- config.delete("authDeviceId");
713
- config.delete("authTier");
714
- config.delete("authExpiresAt");
715
- }
716
- function saveAuth(data) {
717
- config.set("authToken", data.authToken);
718
- config.set("authUserId", data.authUserId);
719
- config.set("authDeviceId", data.authDeviceId);
720
- config.set("authTier", data.authTier);
721
- config.set("authExpiresAt", data.authExpiresAt);
722
- }
723
-
724
- // src/locales/index.ts
725
- var dictionaries = {
726
- zh: zh_default,
727
- en: en_default
728
- };
729
- var currentLang = "zh";
730
- var currentDict = dictionaries.zh;
731
- function initI18n() {
732
- const config2 = getConfig();
733
- currentLang = config2.language || "zh";
734
- currentDict = dictionaries[currentLang] || dictionaries.zh;
735
- }
736
- function setLanguage(lang) {
737
- currentLang = lang;
738
- currentDict = dictionaries[lang] || dictionaries.zh;
739
- }
740
- function t(key, ...args) {
741
- let template = currentDict[key];
742
- if (!template) {
743
- return key;
744
- }
745
- if (args.length > 0) {
746
- args.forEach((arg, index) => {
747
- template = template.replace(`{${index}}`, String(arg));
748
- });
749
- }
750
- return template;
751
- }
752
-
753
- // src/cli/commands/import.ts
754
- import { statSync as statSync2, readdirSync } from "fs";
755
- import { resolve as resolve2, join as join2, extname } from "path";
756
- import * as readline from "readline/promises";
757
- function scanDirectory(dir) {
758
- let results = [];
759
- try {
760
- const list = readdirSync(dir);
761
- for (const file of list) {
762
- const fullPath = join2(dir, file);
763
- const stat = statSync2(fullPath);
764
- if (stat.isDirectory()) {
765
- results = results.concat(scanDirectory(fullPath));
766
- } else {
767
- const ext = extname(fullPath).toLowerCase();
768
- if (ext === ".txt" || ext === ".epub") {
769
- results.push(fullPath);
770
- }
771
- }
772
- }
773
- } catch (err) {
774
- logger.error("Scan error:", err);
775
- }
776
- return results;
777
- }
778
- var importCommand = {
779
- command: "import <file>",
780
- describe: t("cli.import.desc"),
781
- builder: (yargs2) => {
782
- return yargs2.positional("file", {
783
- describe: t("cli.import.help"),
784
- type: "string",
785
- demandOption: true
786
- });
787
- },
788
- handler: async (argv) => {
789
- try {
790
- const targetPath = resolve2(argv.file);
791
- let stat;
792
- try {
793
- stat = statSync2(targetPath);
794
- } catch (e) {
795
- console.log(`${t("cli.import.not_found")} ${targetPath}`);
796
- process.exit(1);
797
- }
798
- const bookService = new BookService();
799
- if (stat.isDirectory()) {
800
- console.log(`${t("cli.import.scan_dir")} ${targetPath}...`);
801
- const files = scanDirectory(targetPath);
802
- if (files.length === 0) {
803
- console.log(`\u2717 ` + t("cli.import.unsupported"));
804
- return;
805
- }
806
- console.log(t("cli.import.found_files"));
807
- files.forEach((f, i) => console.log(` ${i + 1}. ${f}`));
808
- const rl = readline.createInterface({
809
- input: process.stdin,
810
- output: process.stdout
811
- });
812
- const answer = await rl.question(t("cli.import.confirm_batch", files.length) + " ");
813
- rl.close();
814
- if (answer.toLowerCase() === "y") {
815
- for (const file of files) {
816
- try {
817
- const book = await bookService.importBook(file);
818
- console.log(`${t("cli.import.success")} ${book.title} (${book.id})`);
819
- } catch (err) {
820
- console.log(`${t("cli.import.fail")} ${file} - ${err}`);
821
- }
822
- }
823
- } else {
824
- console.log(t("cli.import.canceled"));
825
- }
826
- } else {
827
- const book = await bookService.importBook(argv.file);
828
- console.log(`${t("cli.import.success")} ${book.title} (${book.id})`);
829
- }
830
- } catch (error) {
831
- console.log(`${t("cli.import.fail")} ${error}`);
832
- process.exit(1);
833
- }
834
- }
835
- };
836
-
837
- // src/services/ProgressService.ts
838
- var ProgressService = class {
839
- progressModel = new ProgressModel();
840
- recentModel = new RecentModel();
841
- /**
842
- * 保存阅读进度(退出时调用)
843
- */
844
- saveProgress(bookId, chapterNo, byteOffset, percent) {
845
- const now = Date.now();
846
- this.progressModel.upsert({
847
- book_id: bookId,
848
- chapter_no: chapterNo,
849
- byte_offset: byteOffset,
850
- percent,
851
- updated_at: now,
852
- opened_at: now
853
- });
854
- this.recentModel.recordOpen(bookId);
855
- logger.debug(`\u8FDB\u5EA6\u5DF2\u4FDD\u5B58: book=${bookId}, chapter=${chapterNo}, offset=${byteOffset}, ${(percent * 100).toFixed(1)}%`);
856
- }
857
- /**
858
- * 获取指定书籍的阅读进度(resume 时调用)
859
- */
860
- getProgress(bookId) {
861
- return this.progressModel.findByBookId(bookId);
862
- }
863
- /**
864
- * 获取最近打开的书籍进度(启动时调用,决定 resume 哪本书)
865
- */
866
- getLastOpenedBook() {
867
- return this.progressModel.getLastOpened();
868
- }
869
- };
870
-
871
- // src/ui/renderApp.ts
872
- import React6 from "react";
873
- import { render } from "ink";
874
-
875
- // src/ui/App.tsx
876
- import { useState as useState6 } from "react";
877
- import { Box as Box7, Text as Text7 } from "ink";
878
-
879
- // src/ui/pages/ResumePage.tsx
880
- import { useEffect, useState } from "react";
881
- import { Box, Text, useApp } from "ink";
882
- import { jsx, jsxs } from "react/jsx-runtime";
883
- function ResumePage({ onNavigate }) {
884
- const { exit } = useApp();
885
- const [checking, setChecking] = useState(true);
886
- useEffect(() => {
887
- const progressService = new ProgressService();
888
- const lastProgress = progressService.getLastOpenedBook();
889
- if (!lastProgress) {
890
- setChecking(false);
891
- return;
892
- }
893
- const bookModel = new BookModel();
894
- const book = bookModel.findById(lastProgress.book_id);
895
- if (!book) {
896
- setChecking(false);
897
- return;
898
- }
899
- onNavigate("reader", book.id, lastProgress.byte_offset);
900
- }, [onNavigate]);
901
- if (checking) {
902
- return /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u{1F4D6} \u68C0\u67E5\u9605\u8BFB\u8BB0\u5F55..." }) });
903
- }
904
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
905
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "\u{1F4D6} ReadShell \u2014 \u7EC8\u7AEF\u5185\u8F7B\u9605\u8BFB" }),
906
- /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
907
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u6682\u65E0\u9605\u8BFB\u8BB0\u5F55\u3002" }),
908
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u4F7F\u7528 novel import <file> \u5BFC\u5165\u4E00\u672C\u4E66\u5F00\u59CB\u9605\u8BFB\u3002" }),
909
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u6309 q \u9000\u51FA" }) })
910
- ] })
911
- ] });
912
- }
913
-
914
- // src/ui/pages/LibraryPage.tsx
915
- import { useState as useState2, useEffect as useEffect2 } from "react";
916
- import { Box as Box2, Text as Text2, useApp as useApp2, useInput } from "ink";
917
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
918
- function LibraryPage({ onNavigate }) {
919
- const { exit } = useApp2();
920
- const [books, setBooks] = useState2([]);
921
- const [selectedIndex, setSelectedIndex] = useState2(0);
922
- const [loading, setLoading] = useState2(true);
923
- const isRawModeSupported = process.stdin.isTTY ?? false;
924
- useEffect2(() => {
925
- const bookService = new BookService();
926
- setBooks(bookService.getAllBooks());
927
- setLoading(false);
928
- }, []);
929
- useInput((input, key) => {
930
- if (input === "q") {
931
- exit();
932
- return;
933
- }
934
- if (books.length === 0) return;
935
- if (key.upArrow || input === "k") {
936
- setSelectedIndex((prev) => Math.max(prev - 1, 0));
937
- }
938
- if (key.downArrow || input === "j") {
939
- setSelectedIndex((prev) => Math.min(prev + 1, books.length - 1));
940
- }
941
- if (key.backspace || key.delete || input === "d" || input === "x") {
942
- const selected = books[selectedIndex];
943
- if (selected) {
944
- const bookService = new BookService();
945
- bookService.deleteBook(selected.id);
946
- setBooks((prev) => {
947
- const next = prev.filter((b) => b.id !== selected.id);
948
- if (selectedIndex >= next.length) {
949
- setSelectedIndex(Math.max(0, next.length - 1));
950
- }
951
- return next;
952
- });
953
- }
954
- return;
955
- }
956
- if (key.return) {
957
- const selected = books[selectedIndex];
958
- if (selected) {
959
- const progressService = new ProgressService();
960
- const progress = progressService.getProgress(selected.id);
961
- onNavigate("reader", selected.id, progress?.byte_offset ?? 0);
962
- }
963
- }
964
- }, { isActive: isRawModeSupported });
965
- if (loading) {
966
- return /* @__PURE__ */ jsx2(Box2, { padding: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: t("tui.lib.loading") }) });
967
- }
968
- if (books.length === 0) {
969
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", padding: 1, children: [
970
- /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: t("tui.lib.empty.title") }),
971
- /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
972
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: t("tui.lib.empty.desc") }),
973
- /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: t("common.quit") }) })
974
- ] })
975
- ] });
976
- }
977
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", padding: 1, children: [
978
- /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: t("tui.lib.title", books.length) }),
979
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: t("tui.lib.tips") }),
980
- /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginTop: 1, children: books.map((book, index) => {
981
- const isSelected = index === selectedIndex;
982
- return /* @__PURE__ */ jsx2(Box2, { paddingX: 1, justifyContent: "space-between", children: /* @__PURE__ */ jsxs2(Box2, { children: [
983
- /* @__PURE__ */ jsxs2(
984
- Text2,
985
- {
986
- color: isSelected ? "cyan" : void 0,
987
- bold: isSelected,
988
- children: [
989
- isSelected ? "\u25B8 " : " ",
990
- book.title
991
- ]
992
- }
993
- ),
994
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
995
- " (",
996
- book.format,
997
- ")"
998
- ] })
999
- ] }) }, book.id);
1000
- }) })
1001
- ] });
1002
- }
1003
-
1004
- // src/ui/pages/ReaderPage.tsx
1005
- import { useState as useState5, useEffect as useEffect4, useRef } from "react";
1006
- import { Box as Box6, Text as Text6, useApp as useApp3, useStdout } from "ink";
1007
-
1008
- // src/ui/components/TextRenderer.tsx
1009
- import { Box as Box3, Text as Text3 } from "ink";
1010
- import { jsx as jsx3 } from "react/jsx-runtime";
1011
- function renderLineWithHighlight(line) {
1012
- if (!line) return /* @__PURE__ */ jsx3(Text3, { children: " " });
1013
- const regex = /(「.*?」|“.*?”|『.*?』|《.*?》)/g;
1014
- const parts = line.split(regex);
1015
- return /* @__PURE__ */ jsx3(Text3, { children: parts.map((part, index) => {
1016
- if (regex.test(part)) {
1017
- }
1018
- const isHighlight = index % 2 === 1;
1019
- if (isHighlight) {
1020
- return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: part }, index);
1021
- }
1022
- return /* @__PURE__ */ jsx3(Text3, { children: part }, index);
1023
- }) });
1024
- }
1025
- function TextRenderer({ lines, height, lineSpacing = 0 }) {
1026
- const displayLines = [...lines];
1027
- if (height && displayLines.length < height) {
1028
- const padding = height - displayLines.length;
1029
- for (let i = 0; i < padding; i++) {
1030
- displayLines.push("");
1031
- }
1032
- }
1033
- return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: displayLines.map((line, index) => /* @__PURE__ */ jsx3(Box3, { marginBottom: lineSpacing, children: renderLineWithHighlight(line) }, index)) });
1034
- }
1035
-
1036
- // src/ui/components/StatusBar.tsx
1037
- import { Box as Box4, Text as Text4 } from "ink";
1038
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1039
- function StatusBar({
1040
- bookTitle,
1041
- chapterTitle,
1042
- percent,
1043
- currentPage,
1044
- totalPages,
1045
- remainingTime
1046
- }) {
1047
- const displayPercent = (percent * 100).toFixed(1);
1048
- const titleDisplay = chapterTitle ? `${bookTitle} \xB7 ${chapterTitle}` : bookTitle;
1049
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", justifyContent: "space-between", borderStyle: "single", borderTop: false, borderLeft: false, borderRight: false, paddingX: 1, children: [
1050
- /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs3(Text4, { color: "gray", children: [
1051
- "\u{1F4D6} ",
1052
- titleDisplay
1053
- ] }) }),
1054
- /* @__PURE__ */ jsxs3(Box4, { children: [
1055
- remainingTime && /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
1056
- t("tui.reader.status.remaining", remainingTime),
1057
- " "
1058
- ] }),
1059
- /* @__PURE__ */ jsxs3(Text4, { color: "gray", children: [
1060
- currentPage,
1061
- "/",
1062
- totalPages
1063
- ] }),
1064
- /* @__PURE__ */ jsxs3(Text4, { color: "gray", children: [
1065
- displayPercent,
1066
- "%"
1067
- ] }),
1068
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: t("common.quit") })
1069
- ] })
1070
- ] });
1071
- }
1072
-
1073
- // src/ui/components/ChapterNav.tsx
1074
- import { useState as useState3, useEffect as useEffect3 } from "react";
1075
- import { Box as Box5, Text as Text5, useInput as useInput2 } from "ink";
1076
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1077
- function ChapterNav({
1078
- chapters,
1079
- bookmarks,
1080
- currentChapterId,
1081
- termHeight,
1082
- onSelect,
1083
- onClose
1084
- }) {
1085
- const [activeTab, setActiveTab] = useState3("chapters");
1086
- const isBookmarks = activeTab === "bookmarks";
1087
- const currentList = isBookmarks ? bookmarks : chapters;
1088
- const initialIndex = currentChapterId && !isBookmarks ? Math.max(
1089
- 0,
1090
- chapters.findIndex((c) => c.id === currentChapterId)
1091
- ) : 0;
1092
- const [selectedIndex, setSelectedIndex] = useState3(initialIndex);
1093
- useEffect3(() => {
1094
- setSelectedIndex(isBookmarks ? 0 : initialIndex);
1095
- }, [activeTab, initialIndex, isBookmarks]);
1096
- const pageSize = Math.max(5, termHeight - 6);
1097
- const windowStart = Math.max(0, Math.floor(selectedIndex / pageSize) * pageSize);
1098
- const visibleItems = currentList.slice(windowStart, windowStart + pageSize);
1099
- const isRawModeSupported = process.stdin.isTTY ?? false;
1100
- useInput2(
1101
- (input, key) => {
1102
- if (key.escape || input === "q") {
1103
- onClose();
1104
- return;
1105
- }
1106
- if (key.tab) {
1107
- setActiveTab((prev) => prev === "chapters" ? "bookmarks" : "chapters");
1108
- return;
1109
- }
1110
- if (key.return) {
1111
- if (currentList[selectedIndex]) {
1112
- onSelect(currentList[selectedIndex].byte_offset);
1113
- }
1114
- return;
1115
- }
1116
- if (key.upArrow || input === "k") {
1117
- setSelectedIndex((prev) => Math.max(0, prev - 1));
1118
- }
1119
- if (key.downArrow || input === "j") {
1120
- setSelectedIndex((prev) => Math.min(currentList.length - 1, prev + 1));
1121
- }
1122
- },
1123
- { isActive: isRawModeSupported }
1124
- );
1125
- return /* @__PURE__ */ jsxs4(
1126
- Box5,
1127
- {
1128
- flexDirection: "column",
1129
- borderStyle: "round",
1130
- borderColor: "green",
1131
- paddingX: 2,
1132
- paddingY: 1,
1133
- width: "80%",
1134
- alignSelf: "center",
1135
- marginTop: 2,
1136
- children: [
1137
- /* @__PURE__ */ jsxs4(Box5, { justifyContent: "space-between", marginBottom: 1, children: [
1138
- /* @__PURE__ */ jsxs4(Box5, { children: [
1139
- /* @__PURE__ */ jsxs4(Text5, { bold: true, color: activeTab === "chapters" ? "green" : "gray", children: [
1140
- t("tui.nav.tab.chapters"),
1141
- " "
1142
- ] }),
1143
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: activeTab === "bookmarks" ? "green" : "gray", children: t("tui.nav.tab.bookmarks") })
1144
- ] }),
1145
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: t("tui.nav.tips") })
1146
- ] }),
1147
- visibleItems.length === 0 ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: t("tui.nav.empty") }) : visibleItems.map((item, idx) => {
1148
- const actualIndex = windowStart + idx;
1149
- const isSelected = actualIndex === selectedIndex;
1150
- return /* @__PURE__ */ jsxs4(
1151
- Text5,
1152
- {
1153
- color: isSelected ? "green" : void 0,
1154
- bold: isSelected,
1155
- children: [
1156
- isSelected ? "\u25B6 " : " ",
1157
- item.title
1158
- ]
1159
- },
1160
- item.id
1161
- );
1162
- }),
1163
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, justifyContent: "flex-end", children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: t("tui.nav.page", Math.floor(selectedIndex / pageSize) + 1, Math.ceil(currentList.length / pageSize) || 1) }) })
1164
- ]
1165
- }
1166
- );
1167
- }
1168
-
1169
- // src/ui/hooks/useReader.ts
1170
- import { useState as useState4, useCallback, useMemo } from "react";
1171
- function useReader(pages, initialByteOffset) {
1172
- const initialPage = useMemo(() => {
1173
- if (!initialByteOffset || pages.length === 0) return 0;
1174
- let targetPage = 0;
1175
- for (let i = 0; i < pages.length; i++) {
1176
- if (pages[i].byteOffset <= initialByteOffset) {
1177
- targetPage = i;
1178
- } else {
1179
- break;
1180
- }
1181
- }
1182
- return targetPage;
1183
- }, [pages, initialByteOffset]);
1184
- const [state, setState] = useState4({
1185
- currentPage: initialPage,
1186
- totalPages: pages.length
1187
- });
1188
- const nextPage = useCallback(() => {
1189
- setState((prev) => ({
1190
- ...prev,
1191
- currentPage: Math.min(prev.currentPage + 1, prev.totalPages - 1)
1192
- }));
1193
- }, []);
1194
- const prevPage = useCallback(() => {
1195
- setState((prev) => ({
1196
- ...prev,
1197
- currentPage: Math.max(prev.currentPage - 1, 0)
1198
- }));
1199
- }, []);
1200
- const goToPage = useCallback((pageNum) => {
1201
- setState((prev) => ({
1202
- ...prev,
1203
- currentPage: Math.max(0, Math.min(pageNum, prev.totalPages - 1))
1204
- }));
1205
- }, []);
1206
- const goToOffset = useCallback((byteOffset) => {
1207
- let targetPage = 0;
1208
- for (let i = 0; i < pages.length; i++) {
1209
- if (pages[i].byteOffset <= byteOffset) {
1210
- targetPage = i;
1211
- } else {
1212
- break;
1213
- }
1214
- }
1215
- setState((prev) => ({
1216
- ...prev,
1217
- currentPage: targetPage
1218
- }));
1219
- }, [pages]);
1220
- const getCurrentPage = useCallback(() => {
1221
- return pages[state.currentPage];
1222
- }, [state.currentPage, pages]);
1223
- const getCurrentOffset = useCallback(() => {
1224
- return pages[state.currentPage]?.byteOffset ?? 0;
1225
- }, [state.currentPage, pages]);
1226
- const getPercent = useCallback(() => {
1227
- if (state.totalPages === 0) return 0;
1228
- return (state.currentPage + 1) / state.totalPages;
1229
- }, [state.currentPage, state.totalPages]);
1230
- const isFirstPage = state.currentPage === 0;
1231
- const isLastPage = state.currentPage === state.totalPages - 1;
1232
- return {
1233
- ...state,
1234
- nextPage,
1235
- prevPage,
1236
- goToPage,
1237
- goToOffset,
1238
- getCurrentPage,
1239
- getCurrentOffset,
1240
- getPercent,
1241
- isFirstPage,
1242
- isLastPage
1243
- };
1244
- }
1245
-
1246
- // src/ui/hooks/useKeyboard.ts
1247
- import { useInput as useInput3 } from "ink";
1248
- function useKeyboard(handlers, isActive = true) {
1249
- const isRawModeSupported = process.stdin.isTTY ?? false;
1250
- const shouldListen = isRawModeSupported && isActive;
1251
- useInput3((input, key) => {
1252
- if (input === " " || input === "j" || key.downArrow || input === "f") {
1253
- handlers.onNext?.();
1254
- }
1255
- if (input === "k" || key.upArrow || input === "b") {
1256
- handlers.onPrev?.();
1257
- }
1258
- if (input === "q") {
1259
- handlers.onQuit?.();
1260
- }
1261
- if (input === "c") {
1262
- handlers.onChapterList?.();
1263
- }
1264
- if (input === "?") {
1265
- handlers.onHelp?.();
1266
- }
1267
- if (handlers.onBossKey && (key.escape || input === "esc" || input === "b" || input === "B")) {
1268
- handlers.onBossKey?.();
1269
- }
1270
- if (handlers.onBookmarkAdd && (input === "m" || input === "M")) {
1271
- handlers.onBookmarkAdd?.();
1272
- }
1273
- }, { isActive: shouldListen });
1274
- }
1275
-
1276
- // src/utils/stringWidth.ts
1277
- function getStringWidth(str) {
1278
- let width = 0;
1279
- for (const char of str) {
1280
- width += isFullWidth(char) ? 2 : 1;
1281
- }
1282
- return width;
1283
- }
1284
- function isFullWidth(char) {
1285
- const code = char.codePointAt(0);
1286
- if (code === void 0) return false;
1287
- return (
1288
- // CJK 统一表意字符
1289
- code >= 19968 && code <= 40959 || // CJK 统一表意字符扩展 A
1290
- code >= 13312 && code <= 19903 || // CJK 统一表意字符扩展 B
1291
- code >= 131072 && code <= 173791 || // CJK 兼容表意字符
1292
- code >= 63744 && code <= 64255 || // 全角 ASCII、全角标点
1293
- code >= 65281 && code <= 65376 || code >= 65504 && code <= 65510 || // CJK 标点符号
1294
- code >= 12288 && code <= 12351 || // 日文平假名/片假名
1295
- code >= 12352 && code <= 12543 || // 韩文音节
1296
- code >= 44032 && code <= 55215
1297
- );
1298
- }
1299
-
1300
- // src/utils/paginate.ts
1301
- function paginate(text, width, height, step) {
1302
- const pages = [];
1303
- const rawLines = text.split("\n");
1304
- const actualStep = step || height;
1305
- const wrappedLines = [];
1306
- let currentOffset = 0;
1307
- for (const rawLine of rawLines) {
1308
- const wrapped = wrapLine(rawLine, width);
1309
- for (const line of wrapped) {
1310
- wrappedLines.push({ text: line, byteOffset: currentOffset });
1311
- }
1312
- currentOffset += Buffer.byteLength(rawLine + "\n", "utf-8");
1313
- }
1314
- for (let i = 0; i < wrappedLines.length; i += actualStep) {
1315
- const pageLines = wrappedLines.slice(i, i + height);
1316
- if (pageLines.length === 0) break;
1317
- pages.push({
1318
- lines: pageLines.map((l) => l.text),
1319
- byteOffset: pageLines[0]?.byteOffset ?? 0
1320
- });
1321
- if (i + actualStep >= wrappedLines.length) break;
1322
- }
1323
- return pages;
1324
- }
1325
- function wrapLine(line, width) {
1326
- if (line.length === 0) return [""];
1327
- const result = [];
1328
- let currentLine = "";
1329
- let currentWidth = 0;
1330
- for (const char of line) {
1331
- const charWidth = getStringWidth(char);
1332
- if (currentWidth + charWidth > width) {
1333
- result.push(currentLine);
1334
- currentLine = char;
1335
- currentWidth = charWidth;
1336
- } else {
1337
- currentLine += char;
1338
- currentWidth += charWidth;
1339
- }
1340
- }
1341
- if (currentLine.length > 0) {
1342
- result.push(currentLine);
1343
- }
1344
- return result.length > 0 ? result : [""];
1345
- }
1346
-
1347
- // src/services/ChapterService.ts
1348
- var ChapterService = class {
1349
- chapterModel = new ChapterModel();
1350
- /**
1351
- * 获取指定书籍的所有章节
1352
- */
1353
- getChapters(bookId) {
1354
- return this.chapterModel.findByBookId(bookId);
1355
- }
1356
- /**
1357
- * 获取指定章节信息
1358
- */
1359
- getChapter(bookId, chapterNo) {
1360
- return this.chapterModel.findChapter(bookId, chapterNo);
1361
- }
1362
- /**
1363
- * 获取章节总数
1364
- */
1365
- getChapterCount(bookId) {
1366
- return this.chapterModel.getChapterCount(bookId);
1367
- }
1368
- /**
1369
- * 获取指定书籍下的所有章节
1370
- */
1371
- getChaptersByBookId(bookId) {
1372
- return this.chapterModel.findByBookId(bookId);
1373
- }
1374
- /**
1375
- * 根据 offset 查询当前所属章节(用于高亮当前所在章)
1376
- */
1377
- getChapterByOffset(bookId, byteOffset) {
1378
- const chapters = this.chapterModel.findByBookId(bookId);
1379
- if (chapters.length === 0) return void 0;
1380
- let current;
1381
- for (const chapter of chapters) {
1382
- if (chapter.byte_offset <= byteOffset) {
1383
- current = chapter;
1384
- } else {
1385
- break;
1386
- }
1387
- }
1388
- return current;
1389
- }
1390
- };
1391
-
1392
- // src/services/RecentService.ts
1393
- var RecentService = class {
1394
- recentModel = new RecentModel();
1395
- bookModel = new BookModel();
1396
- /**
1397
- * 获取最近阅读的书籍列表(包含书籍详情)
1398
- */
1399
- getRecentBooks(limit = 20) {
1400
- const recentRecords = this.recentModel.getRecent(limit);
1401
- return recentRecords.map((record) => this.bookModel.findById(record.book_id)).filter((book) => book !== void 0);
1402
- }
1403
- /**
1404
- * 记录打开事件
1405
- */
1406
- recordOpen(bookId) {
1407
- this.recentModel.recordOpen(bookId);
1408
- }
1409
- };
1410
-
1411
- // src/db/models/Bookmark.ts
1412
- var BookmarkModel = class {
1413
- /**
1414
- * 插入书签
1415
- */
1416
- insert(bookmark) {
1417
- const db2 = getDb();
1418
- db2.prepare(`
1419
- INSERT INTO bookmarks (book_id, title, byte_offset, created_at)
1420
- VALUES (?, ?, ?, ?)
1421
- `).run(bookmark.book_id, bookmark.title, bookmark.byte_offset, bookmark.created_at);
1422
- }
1423
- /**
1424
- * 获取指定书籍的所有书签
1425
- */
1426
- findByBookId(bookId) {
1427
- const db2 = getDb();
1428
- return db2.prepare("SELECT * FROM bookmarks WHERE book_id = ? ORDER BY created_at DESC").all(bookId);
1429
- }
1430
- /**
1431
- * 获取指定书签
1432
- */
1433
- findById(id) {
1434
- const db2 = getDb();
1435
- return db2.prepare("SELECT * FROM bookmarks WHERE id = ?").get(id);
1436
- }
1437
- /**
1438
- * 获取书籍书签总数
1439
- */
1440
- getCount(bookId) {
1441
- const db2 = getDb();
1442
- const result = db2.prepare("SELECT COUNT(*) as count FROM bookmarks WHERE book_id = ?").get(bookId);
1443
- return result.count;
1444
- }
1445
- /**
1446
- * 删除书签
1447
- */
1448
- delete(id) {
1449
- const db2 = getDb();
1450
- db2.prepare("DELETE FROM bookmarks WHERE id = ?").run(id);
1451
- }
1452
- /**
1453
- * 移除整本书的书签 (配合彻底清理书籍使用)
1454
- */
1455
- deleteByBookId(bookId) {
1456
- const db2 = getDb();
1457
- db2.prepare("DELETE FROM bookmarks WHERE book_id = ?").run(bookId);
1458
- }
1459
- };
1460
-
1461
- // src/services/BookmarkService.ts
1462
- var BookmarkService = class {
1463
- bookmarkModel;
1464
- constructor() {
1465
- this.bookmarkModel = new BookmarkModel();
1466
- }
1467
- /**
1468
- * 增加一条书签
1469
- * @param title 该书签展现给用户的文案(一句话大纲)
1470
- */
1471
- addBookmark(bookId, title, byteOffset) {
1472
- this.bookmarkModel.insert({
1473
- book_id: bookId,
1474
- title,
1475
- byte_offset: byteOffset,
1476
- created_at: Date.now()
1477
- });
1478
- }
1479
- /**
1480
- * 罗列该书全部的书签
1481
- */
1482
- getBookmarksByBookId(bookId) {
1483
- return this.bookmarkModel.findByBookId(bookId);
1484
- }
1485
- /**
1486
- * 删掉对应书签
1487
- */
1488
- removeBookmark(id) {
1489
- this.bookmarkModel.delete(id);
1490
- }
1491
- };
1492
-
1493
- // src/utils/bossKey.ts
1494
- var isBossKeyEnabled = false;
1495
- function triggerBossKey() {
1496
- isBossKeyEnabled = true;
1497
- }
1498
- function isBossKeyActive() {
1499
- return isBossKeyEnabled;
1500
- }
1501
- var fakeLogs = {
1502
- nodejs: `
1503
- file:///home/user/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916
1504
- throw new Error(\`[vite] Failed to resolve module import "./App.vue". Check if the file exists.\`);
1505
- ^
1506
-
1507
- Error: [vite] Failed to resolve module import "./App.vue". Check if the file exists.
1508
- at Object.run (file:///home/user/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916:13)
1509
- at async file:///home/user/project/node_modules/vite/dist/node/cli.js:722:7
1510
- at async startVite (file:///home/user/project/node_modules/vite/dist/node/cli.js:700:5)
1511
- at async Object.handler (file:///home/user/project/node_modules/vite/dist/node/cli.js:650:1)
1512
-
1513
- Node.js v20.11.0
1514
- `,
1515
- python: `
1516
- Traceback (most recent call last):
1517
- File "/home/user/project/main.py", line 42, in <module>
1518
- result = process_data(df)
1519
- File "/home/user/project/utils/pipeline.py", line 118, in process_data
1520
- return df.groupby("user_id").apply(transform)
1521
- File "/home/user/project/utils/pipeline.py", line 97, in transform
1522
- raise ValueError(f"Missing required column: '{col}'")
1523
- ValueError: Missing required column: 'timestamp'
1524
-
1525
- During handling of the above exception, another exception occurred:
1526
-
1527
- Traceback (most recent call last):
1528
- File "/home/user/project/main.py", line 47, in <module>
1529
- raise RuntimeError("Pipeline failed. Check logs for details.")
1530
- RuntimeError: Pipeline failed. Check logs for details.
1531
- `,
1532
- java: `
1533
- Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
1534
- at com.example.app.StringUtils.process(StringUtils.java:34)
1535
- at com.example.app.DataProcessor.run(DataProcessor.java:112)
1536
- at com.example.app.Main.main(Main.java:21)
1537
-
1538
- BUILD FAILURE
1539
- [ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.11.0:compile
1540
- [ERROR] -> [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
1541
- `,
1542
- c: `
1543
- make[2]: *** [CMakeFiles/app.dir/src/main.c.o] Error 1
1544
- make[1]: *** [CMakeFiles/app.dir/all] Error 2
1545
- make: *** [all] Error 2
1546
-
1547
- /home/user/project/src/main.c:87:5: error: use of undeclared identifier 'ctx'
1548
- ctx->buffer = malloc(sizeof(Buffer));
1549
- ^
1550
- /home/user/project/src/main.c:102:12: warning: implicit declaration of function 'init_buffer' [-Wimplicit-function-declaration]
1551
- result = init_buffer(ctx, DEFAULT_SIZE);
1552
- ^
1553
- 2 errors, 1 warning generated.
1554
- `,
1555
- go: `
1556
- # command-line-arguments
1557
- ./main.go:58:13: undefined: parseConfig
1558
- ./main.go:74:9: cannot use result (variable of type *Response) as type Handler
1559
- ./main.go:91:2: declared and not used: errCh
1560
-
1561
- go: github.com/example/app@v1.3.2: reading github.com/example/app/go.mod at revision v1.3.2: unknown revision v1.3.2
1562
- exit status 1
1563
- `
1564
- };
1565
- function performBossKeyAction() {
1566
- process.stdout.write("\x1B[3J\x1B[2J\x1B[1;1H");
1567
- const lang = getConfig().bossKeyLang ?? "nodejs";
1568
- const fakeLog = fakeLogs[lang];
1569
- process.stdout.write(fakeLog + "\n");
1570
- process.exit(0);
1571
- }
1572
-
1573
- // src/utils/time.ts
1574
- function estimateReadingTime(charCount, isChinese = true) {
1575
- const charsPerMinute = isChinese ? 500 : 1250;
1576
- return Math.ceil(charCount / charsPerMinute);
1577
- }
1578
- function formatReadingTime(minutes) {
1579
- if (minutes < 60) {
1580
- return `${minutes} \u5206\u949F`;
1581
- }
1582
- const hours = Math.floor(minutes / 60);
1583
- const mins = minutes % 60;
1584
- return mins > 0 ? `${hours} \u5C0F\u65F6 ${mins} \u5206\u949F` : `${hours} \u5C0F\u65F6`;
1585
- }
1586
-
1587
- // src/services/CloudSyncService.ts
1588
- var API_BASE = "https://api.readshell.com";
1589
- var CloudSyncService = class {
1590
- constructor(token, deviceId, tier) {
1591
- this.token = token;
1592
- this.deviceId = deviceId;
1593
- this.tier = tier;
1594
- }
1595
- async apiFetch(path, init = {}) {
1596
- return fetch(`${API_BASE}${path}`, {
1597
- ...init,
1598
- headers: {
1599
- "Content-Type": "application/json",
1600
- Authorization: `Bearer ${this.token}`,
1601
- ...init.headers
1602
- }
1603
- });
1604
- }
1605
- /**
1606
- * 推送单本书的进度到云端
1607
- */
1608
- async pushProgress(bookId, progress) {
1609
- const payload = {
1610
- deviceId: this.deviceId,
1611
- books: [],
1612
- progress: [{
1613
- bookId,
1614
- byteOffset: progress.byteOffset,
1615
- percentage: progress.percent,
1616
- clientUpdatedAt: new Date(progress.updatedAt).toISOString()
1617
- }],
1618
- bookmarks: []
1619
- };
1620
- await this.apiFetch("/sync/push", {
1621
- method: "POST",
1622
- body: JSON.stringify(payload)
1623
- }).catch(() => {
1624
- });
1625
- }
1626
- /**
1627
- * 推送单本书的书签到云端
1628
- */
1629
- async pushBookmarks(bookId, bookmarks) {
1630
- const syncBookmarks = bookmarks.map((b) => ({
1631
- id: String(b.id ?? `${b.book_id}-${b.byte_offset}`),
1632
- bookId: b.book_id,
1633
- byteOffset: b.byte_offset,
1634
- label: b.title,
1635
- deleted: false,
1636
- deletedAt: null,
1637
- createdAt: new Date(b.created_at).toISOString()
1638
- }));
1639
- const payload = {
1640
- deviceId: this.deviceId,
1641
- books: [],
1642
- progress: [],
1643
- bookmarks: syncBookmarks
1644
- };
1645
- await this.apiFetch("/sync/push", {
1646
- method: "POST",
1647
- body: JSON.stringify(payload)
1648
- }).catch(() => {
1649
- });
1650
- }
1651
- /**
1652
- * 拉取云端数据并合并到本地 SQLite
1653
- */
1654
- async pull() {
1655
- try {
1656
- const res = await this.apiFetch("/sync/pull");
1657
- if (!res.ok) return 0;
1658
- const json = await res.json();
1659
- if (!json.success || !json.data) return 0;
1660
- const progressModel = new ProgressModel();
1661
- let count = 0;
1662
- for (const p of json.data.progress) {
1663
- progressModel.upsert({
1664
- book_id: p.bookId,
1665
- chapter_no: 0,
1666
- byte_offset: p.byteOffset,
1667
- percent: p.percentage,
1668
- updated_at: new Date(p.clientUpdatedAt).getTime(),
1669
- opened_at: new Date(p.clientUpdatedAt).getTime()
1670
- });
1671
- count++;
1672
- }
1673
- return count;
1674
- } catch {
1675
- return 0;
1676
- }
1677
- }
1678
- /**
1679
- * 完整双向同步:推送本地所有进度和书签,然后拉取云端数据
1680
- */
1681
- async fullSync() {
1682
- const progressModel = new ProgressModel();
1683
- const bookmarkModel = new BookmarkModel();
1684
- const bookModel = new BookModel();
1685
- const books = bookModel.findAll();
1686
- const bookPayloads = [];
1687
- const progressPayloads = [];
1688
- const bookmarkPayloads = [];
1689
- for (const book of books) {
1690
- bookPayloads.push({
1691
- id: book.id,
1692
- title: book.title,
1693
- author: book.author,
1694
- format: book.format,
1695
- fileHash: book.file_hash
1696
- });
1697
- const progress = progressModel.findByBookId(book.id);
1698
- if (progress) {
1699
- progressPayloads.push({
1700
- bookId: book.id,
1701
- byteOffset: progress.byte_offset,
1702
- percentage: progress.percent,
1703
- clientUpdatedAt: new Date(progress.updated_at).toISOString()
1704
- });
1705
- }
1706
- const bookmarks = bookmarkModel.findByBookId(book.id);
1707
- for (const b of bookmarks) {
1708
- bookmarkPayloads.push({
1709
- id: String(b.id ?? `${b.book_id}-${b.byte_offset}`),
1710
- bookId: b.book_id,
1711
- byteOffset: b.byte_offset,
1712
- label: b.title,
1713
- deleted: false,
1714
- deletedAt: null,
1715
- createdAt: new Date(b.created_at).toISOString()
1716
- });
1717
- }
1718
- }
1719
- const pushed = progressPayloads.length + bookmarkPayloads.length;
1720
- try {
1721
- await this.apiFetch("/sync/push", {
1722
- method: "POST",
1723
- body: JSON.stringify({
1724
- deviceId: this.deviceId,
1725
- books: bookPayloads,
1726
- progress: progressPayloads,
1727
- bookmarks: bookmarkPayloads
1728
- })
1729
- });
1730
- } catch {
1731
- }
1732
- const pulled = await this.pull();
1733
- return { pushed, pulled };
1734
- }
1735
- };
1736
- function createSyncService() {
1737
- if (!isLoggedIn()) return null;
1738
- const token = getAuthToken();
1739
- const deviceId = getAuthDeviceId();
1740
- const tier = getAuthTier();
1741
- if (!token || !deviceId || !tier || tier === "free") return null;
1742
- return new CloudSyncService(token, deviceId, tier);
1743
- }
1744
-
1745
- // src/ui/pages/ReaderPage.tsx
1746
- import { Fragment, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1747
- function ReaderContent({
1748
- book,
1749
- bookId,
1750
- pages,
1751
- initialByteOffset,
1752
- termHeight,
1753
- contentHeight,
1754
- lineSpacing
1755
- }) {
1756
- const { exit } = useApp3();
1757
- const [chapterTitle, setChapterTitle] = useState5();
1758
- const [currentChapter, setCurrentChapter] = useState5();
1759
- const [showChapterNav, setShowChapterNav] = useState5(false);
1760
- const [allChapters, setAllChapters] = useState5([]);
1761
- const [allBookmarks, setAllBookmarks] = useState5([]);
1762
- const [toastMessage, setToastMessage] = useState5(null);
1763
- const progressServiceRef = useRef(new ProgressService());
1764
- const chapterServiceRef = useRef(new ChapterService());
1765
- const bookmarkServiceRef = useRef(new BookmarkService());
1766
- const reader = useReader(pages, initialByteOffset);
1767
- const saveReadingProgress = () => {
1768
- const offset = reader.getCurrentOffset();
1769
- const percent = reader.getPercent();
1770
- const chapter = chapterServiceRef.current.getChapterByOffset(bookId, offset);
1771
- const chapterNo = chapter?.chapter_no ?? 0;
1772
- progressServiceRef.current.saveProgress(bookId, chapterNo, offset, percent);
1773
- logger.debug(`\u8FDB\u5EA6\u5DF2\u4FDD\u5B58: offset=${offset}, ${(percent * 100).toFixed(1)}%`);
1774
- };
1775
- useEffect4(() => {
1776
- const currentOffset = reader.getCurrentOffset();
1777
- const chapter = chapterServiceRef.current.getChapterByOffset(bookId, currentOffset);
1778
- setCurrentChapter(chapter ?? void 0);
1779
- setChapterTitle(chapter?.title ?? void 0);
1780
- }, [reader.currentPage, bookId]);
1781
- useEffect4(() => {
1782
- const chaptersList = chapterServiceRef.current.getChaptersByBookId(bookId);
1783
- setAllChapters(chaptersList);
1784
- if (showChapterNav) {
1785
- setAllBookmarks(bookmarkServiceRef.current.getBookmarksByBookId(bookId));
1786
- }
1787
- }, [bookId, showChapterNav]);
1788
- const handleAddBookmark = () => {
1789
- const currentPageInfo = reader.getCurrentPage();
1790
- if (!currentPageInfo) return;
1791
- let markTitle = "\u65E0\u6807\u9898\u4E66\u7B7E";
1792
- for (const line of currentPageInfo.lines) {
1793
- const stripped = line.trim();
1794
- if (stripped.length > 0) {
1795
- markTitle = stripped.slice(0, 15) + (stripped.length > 15 ? "..." : "");
1796
- break;
1797
- }
1798
- }
1799
- const currentOffset = reader.getCurrentOffset();
1800
- bookmarkServiceRef.current.addBookmark(bookId, markTitle, currentOffset);
1801
- setToastMessage(t("tui.reader.bookmark_add", markTitle));
1802
- setTimeout(() => setToastMessage(null), 2e3);
1803
- };
1804
- useEffect4(() => {
1805
- return () => {
1806
- saveReadingProgress();
1807
- const syncService = createSyncService();
1808
- if (syncService) {
1809
- const offset = reader.getCurrentOffset();
1810
- const percent = reader.getPercent();
1811
- syncService.pushProgress(bookId, {
1812
- byteOffset: offset,
1813
- percent,
1814
- updatedAt: Date.now()
1815
- }).catch(() => {
1816
- });
1817
- }
1818
- };
1819
- }, [bookId, reader]);
1820
- useKeyboard(
1821
- {
1822
- onNext: () => reader.nextPage(),
1823
- onPrev: () => reader.prevPage(),
1824
- onQuit: () => exit(),
1825
- onChapterList: () => setShowChapterNav(true),
1826
- onBossKey: () => {
1827
- saveReadingProgress();
1828
- triggerBossKey();
1829
- exit();
1830
- },
1831
- onBookmarkAdd: handleAddBookmark
1832
- },
1833
- !showChapterNav
1834
- // 如果浮层显示,则停止普通的阅读快捷键
1835
- );
1836
- const currentPage = reader.getCurrentPage();
1837
- const currentLines = currentPage?.lines ?? [];
1838
- const calculatedContentHeight = Math.max(1, termHeight - 2);
1839
- const totalChars = (book.file_size ?? 0) / 3;
1840
- const remainingChars = Math.max(0, totalChars * (1 - reader.getPercent()));
1841
- const remainingMinutes = estimateReadingTime(remainingChars, true);
1842
- const remainingTimeStr = formatReadingTime(remainingMinutes);
1843
- return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", height: termHeight, children: !showChapterNav ? /* @__PURE__ */ jsxs5(Fragment, { children: [
1844
- /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", flexGrow: 1, paddingX: 1, children: /* @__PURE__ */ jsx6(TextRenderer, { lines: currentLines, height: calculatedContentHeight, lineSpacing }) }),
1845
- /* @__PURE__ */ jsx6(
1846
- StatusBar,
1847
- {
1848
- bookTitle: book.title,
1849
- percent: reader.getPercent(),
1850
- chapterTitle,
1851
- currentPage: reader.currentPage + 1,
1852
- totalPages: reader.totalPages,
1853
- remainingTime: remainingTimeStr
1854
- }
1855
- ),
1856
- 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 }) })
1857
- ] }) : /* @__PURE__ */ jsx6(
1858
- ChapterNav,
1859
- {
1860
- chapters: allChapters,
1861
- bookmarks: allBookmarks,
1862
- currentChapterId: currentChapter?.id,
1863
- termHeight,
1864
- onSelect: (offset) => {
1865
- reader.goToOffset(offset);
1866
- setShowChapterNav(false);
1867
- },
1868
- onClose: () => setShowChapterNav(false)
1869
- }
1870
- ) });
1871
- }
1872
- function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
1873
- const { exit } = useApp3();
1874
- const { stdout } = useStdout();
1875
- const [book, setBook] = useState5(null);
1876
- const [pages, setPages] = useState5(null);
1877
- const [error, setError] = useState5(null);
1878
- const termWidth = stdout?.columns ?? 80;
1879
- const termHeight = stdout?.rows ?? 24;
1880
- const appConfig = getConfig();
1881
- const lineSpacing = appConfig.lineSpacing || 0;
1882
- const contentHeight = Math.max(Math.floor((termHeight - 3) / (1 + lineSpacing)), 2);
1883
- useEffect4(() => {
1884
- try {
1885
- const bookModel = new BookModel();
1886
- const bookRecord = bookModel.findById(bookId);
1887
- if (!bookRecord) {
1888
- setError(`\u4E66\u7C4D\u4E0D\u5B58\u5728: ${bookId}`);
1889
- return;
1890
- }
1891
- setBook(bookRecord);
1892
- const recentService = new RecentService();
1893
- recentService.recordOpen(bookId);
1894
- parseFile(bookRecord.file_path, bookRecord.format).then((parsed) => {
1895
- const stepSize = appConfig.readingMode === "scroll" ? Math.max(1, Math.floor(contentHeight / 2)) : contentHeight;
1896
- const paginatedPages = paginate(parsed.content, termWidth - 2, contentHeight, stepSize);
1897
- setPages(paginatedPages);
1898
- logger.debug(`\u52A0\u8F7D\u5B8C\u6210: ${bookRecord.title}, ${paginatedPages.length} \u9875, \u6A21\u5F0F: ${appConfig.readingMode}`);
1899
- }).catch((err) => {
1900
- setError(`\u5185\u5BB9\u89E3\u6790\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`);
1901
- });
1902
- } catch (err) {
1903
- setError(`\u52A0\u8F7D\u8FC7\u7A0B\u51FA\u9519: ${err instanceof Error ? err.message : String(err)}`);
1904
- }
1905
- }, [bookId, termWidth, contentHeight]);
1906
- useKeyboard({
1907
- onQuit: () => exit()
1908
- });
1909
- if (error) {
1910
- return /* @__PURE__ */ jsxs5(Box6, { padding: 1, flexDirection: "column", children: [
1911
- /* @__PURE__ */ jsxs5(Text6, { color: "red", children: [
1912
- "\u2717 ",
1913
- error
1914
- ] }),
1915
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("common.quit") })
1916
- ] });
1917
- }
1918
- if (!book || !pages) {
1919
- return /* @__PURE__ */ jsx6(Box6, { padding: 1, children: /* @__PURE__ */ jsxs5(Text6, { color: "cyan", children: [
1920
- "\u{1F4D6} ",
1921
- t("tui.reader.loading")
1922
- ] }) });
1923
- }
1924
- return /* @__PURE__ */ jsx6(
1925
- ReaderContent,
1926
- {
1927
- book,
1928
- bookId,
1929
- pages,
1930
- initialByteOffset,
1931
- termHeight,
1932
- contentHeight,
1933
- lineSpacing
1934
- }
1935
- );
1936
- }
1937
-
1938
- // src/ui/App.tsx
1939
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1940
- function App({ initialPage = "resume", bookId, initialByteOffset }) {
1941
- const [currentPage, setCurrentPage] = useState6(initialPage);
1942
- const [currentBookId, setCurrentBookId] = useState6(bookId);
1943
- const [currentByteOffset, setCurrentByteOffset] = useState6(initialByteOffset);
1944
- const navigateTo = (page, targetBookId, byteOffset) => {
1945
- setCurrentPage(page);
1946
- if (targetBookId) setCurrentBookId(targetBookId);
1947
- if (byteOffset !== void 0) setCurrentByteOffset(byteOffset);
1948
- };
1949
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", width: "100%", children: [
1950
- currentPage === "resume" && /* @__PURE__ */ jsx7(ResumePage, { onNavigate: navigateTo }),
1951
- currentPage === "library" && /* @__PURE__ */ jsx7(LibraryPage, { onNavigate: navigateTo }),
1952
- currentPage === "reader" && currentBookId && /* @__PURE__ */ jsx7(
1953
- ReaderPage,
1954
- {
1955
- bookId: currentBookId,
1956
- initialByteOffset: currentByteOffset,
1957
- onNavigate: navigateTo
1958
- }
1959
- ),
1960
- currentPage === "reader" && !currentBookId && /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: "red", children: "\u9519\u8BEF: \u672A\u6307\u5B9A\u4E66\u7C4D" }) })
1961
- ] });
1962
- }
1963
-
1964
- // src/ui/renderApp.ts
1965
- function renderApp(options = {}) {
1966
- const { initialPage = "resume", bookId, initialByteOffset } = options;
1967
- const { waitUntilExit } = render(
1968
- React6.createElement(App, {
1969
- initialPage,
1970
- bookId,
1971
- initialByteOffset
1972
- })
1973
- );
1974
- waitUntilExit().then(() => {
1975
- if (isBossKeyActive()) {
1976
- performBossKeyAction();
1977
- }
1978
- process.exit(0);
1979
- }).catch(() => {
1980
- process.exit(1);
1981
- });
1982
- }
1983
-
1984
- // src/cli/commands/resume.ts
1985
- var resumeCommand = {
1986
- command: "resume",
1987
- describe: t("cli.resume.desc"),
1988
- handler: async () => {
1989
- try {
1990
- const progressService = new ProgressService();
1991
- const lastProgress = progressService.getLastOpenedBook();
1992
- if (!lastProgress) {
1993
- console.log(t("cli.resume.none"));
1994
- return;
1995
- }
1996
- const bookService = new BookService();
1997
- const book = bookService.findBook(lastProgress.book_id);
1998
- if (!book) {
1999
- console.log(t("cli.resume.none"));
2000
- return;
2001
- }
2002
- logger.debug(`\u6062\u590D\u9605\u8BFB: ${book.title}, offset=${lastProgress.byte_offset}`);
2003
- renderApp({
2004
- initialPage: "reader",
2005
- bookId: lastProgress.book_id,
2006
- initialByteOffset: lastProgress.byte_offset
2007
- });
2008
- } catch (error) {
2009
- logger.error("\u6062\u590D\u9605\u8BFB\u5931\u8D25:", error);
2010
- process.exit(1);
2011
- }
2012
- }
2013
- };
2014
-
2015
- // src/cli/commands/open.ts
2016
- var openCommand = {
2017
- command: "open <target>",
2018
- describe: t("cli.open.desc"),
2019
- builder: (yargs2) => {
2020
- return yargs2.positional("target", {
2021
- describe: t("cli.open.help"),
2022
- type: "string",
2023
- demandOption: true
2024
- });
2025
- },
2026
- handler: async (argv) => {
2027
- try {
2028
- const bookService = new BookService();
2029
- const book = bookService.findBook(argv.target);
2030
- if (!book) {
2031
- console.log(`${t("cli.open.not_found")} ${argv.target}`);
2032
- process.exit(1);
2033
- }
2034
- const progressService = new ProgressService();
2035
- const progress = progressService.getProgress(book.id);
2036
- const byteOffset = progress?.byte_offset ?? 0;
2037
- logger.debug(`\u6253\u5F00: ${book.title}, offset=${byteOffset}`);
2038
- renderApp({
2039
- initialPage: "reader",
2040
- bookId: book.id,
2041
- initialByteOffset: byteOffset
2042
- });
2043
- } catch (error) {
2044
- logger.error("\u6253\u5F00\u5931\u8D25:", error);
2045
- process.exit(1);
2046
- }
2047
- }
2048
- };
2049
-
2050
- // src/cli/commands/library.ts
2051
- var libraryCommand = {
2052
- command: "list",
2053
- aliases: ["library"],
2054
- describe: t("cli.library.desc"),
2055
- builder: (yargs2) => {
2056
- return yargs2.option("search", {
2057
- alias: "s",
2058
- describe: t("cli.library.help"),
2059
- type: "string"
2060
- });
2061
- },
2062
- handler: async (argv) => {
2063
- try {
2064
- if (argv.search) {
2065
- const bookService = new BookService();
2066
- const books = bookService.searchBooks(argv.search);
2067
- if (books.length === 0) {
2068
- console.log(t("cli.library.search_none", argv.search));
2069
- return;
2070
- }
2071
- console.log(t("cli.library.search_result", books.length));
2072
- books.forEach((book, index) => {
2073
- console.log(` ${index + 1}. ${book.title} [${book.id}] (${book.format})`);
2074
- });
2075
- return;
2076
- }
2077
- renderApp({ initialPage: "library" });
2078
- } catch (error) {
2079
- logger.error("\u83B7\u53D6\u4E66\u67B6\u5931\u8D25:", error);
2080
- process.exit(1);
2081
- }
2082
- }
2083
- };
2084
-
2085
- // src/cli/commands/remove.ts
2086
- var removeCommand = {
2087
- command: "remove <target>",
2088
- describe: t("cli.remove.desc"),
2089
- builder: (yargs2) => {
2090
- return yargs2.positional("target", {
2091
- describe: t("cli.remove.help"),
2092
- type: "string",
2093
- demandOption: true
2094
- });
2095
- },
2096
- handler: async (argv) => {
2097
- try {
2098
- const bookService = new BookService();
2099
- const book = bookService.findBook(argv.target);
2100
- if (!book) {
2101
- console.log(`${t("cli.remove.not_found")} ${argv.target}`);
2102
- process.exit(1);
2103
- }
2104
- bookService.deleteBook(book.id);
2105
- console.log(`${t("cli.remove.success")} ${book.title}`);
2106
- } catch (error) {
2107
- logger.error("\u79FB\u9664\u4E66\u7C4D\u5931\u8D25:", error);
2108
- process.exit(1);
2109
- }
2110
- }
2111
- };
2112
-
2113
- // src/cli/commands/lang.ts
2114
- var langCommand = {
2115
- command: "lang <target>",
2116
- describe: t("cli.lang.desc"),
2117
- builder: (yargs2) => {
2118
- return yargs2.positional("target", {
2119
- describe: t("cli.lang.help"),
2120
- type: "string",
2121
- choices: ["zh", "en"],
2122
- demandOption: true
2123
- });
2124
- },
2125
- handler: (argv) => {
2126
- const lang = argv.target;
2127
- if (lang === "zh" || lang === "en") {
2128
- setConfig("language", lang);
2129
- setLanguage(lang);
2130
- console.log(t("cli.lang.success", lang));
2131
- } else {
2132
- console.log(t("cli.lang.unsupported", lang));
2133
- process.exit(1);
2134
- }
2135
- }
2136
- };
2137
-
2138
- // src/cli/commands/update.ts
2139
- import { execSync } from "child_process";
2140
- var updateCommand = {
2141
- command: "update",
2142
- describe: t("cli.update.desc"),
2143
- handler: async () => {
2144
- try {
2145
- console.log(t("cli.update.checking"));
2146
- const localVersion = true ? "0.3.6" : "0.2.2";
2147
- const npmOutput = execSync("npm view readshell-pro version", { encoding: "utf-8" });
2148
- const latestVersion = npmOutput.trim();
2149
- if (!latestVersion) {
2150
- throw new Error("Could not fetch npm version");
2151
- }
2152
- if (latestVersion === localVersion) {
2153
- console.log(t("cli.update.latest", localVersion));
2154
- return;
2155
- }
2156
- console.log(t("cli.update.updating", latestVersion, localVersion));
2157
- execSync("npm install -g readshell-pro@latest", { stdio: "inherit" });
2158
- console.log(t("cli.update.success"));
2159
- } catch (error) {
2160
- const msg = error instanceof Error ? error.message : String(error);
2161
- console.log(t("cli.update.fail", msg));
2162
- logger.error("\u66F4\u65B0\u5931\u8D25:", error);
2163
- process.exit(1);
2164
- }
2165
- }
2166
- };
2167
-
2168
- // src/cli/commands/login.ts
2169
- var API_BASE2 = "https://api.readshell.com";
2170
- var POLL_INTERVAL_MS = 5e3;
2171
- var TIMEOUT_MS = 5 * 60 * 1e3;
2172
- var loginCommand = {
2173
- command: "login",
2174
- describe: "Login to ReadShell Pro (cloud sync)",
2175
- handler: async () => {
2176
- if (isLoggedIn()) {
2177
- const tier = getAuthTier() ?? "unknown";
2178
- console.log(t("cli.login.already", tier));
2179
- return;
2180
- }
2181
- const initRes = await fetch(`${API_BASE2}/auth/device/init`, { method: "POST" });
2182
- if (!initRes.ok) {
2183
- console.error("Failed to start login flow. Please try again.");
2184
- process.exit(1);
2185
- }
2186
- const initJson = await initRes.json();
2187
- if (!initJson.success || !initJson.data) {
2188
- console.error("Failed to start login flow. Please try again.");
2189
- process.exit(1);
2190
- }
2191
- const { deviceCode, userCode, verificationUrl } = initJson.data;
2192
- console.log(t("cli.login.open_browser", verificationUrl));
2193
- console.log(t("cli.login.user_code", userCode));
2194
- console.log(t("cli.login.waiting"));
2195
- const deadline = Date.now() + TIMEOUT_MS;
2196
- while (Date.now() < deadline) {
2197
- await new Promise((resolve3) => setTimeout(resolve3, POLL_INTERVAL_MS));
2198
- const pollRes = await fetch(`${API_BASE2}/auth/device/poll`, {
2199
- method: "POST",
2200
- headers: { "Content-Type": "application/json" },
2201
- body: JSON.stringify({ deviceCode })
2202
- }).catch(() => null);
2203
- if (!pollRes || !pollRes.ok) continue;
2204
- const pollJson = await pollRes.json();
2205
- if (pollJson.success && pollJson.data) {
2206
- const { accessToken, deviceId, userId, expiresAt, tier } = pollJson.data;
2207
- saveAuth({
2208
- authToken: accessToken,
2209
- authUserId: userId,
2210
- authDeviceId: deviceId,
2211
- authTier: tier,
2212
- authExpiresAt: expiresAt
2213
- });
2214
- console.log(t("cli.login.success", tier));
2215
- if (tier !== "free") {
2216
- console.log(t("cli.login.migrate_hint"));
2217
- }
2218
- return;
2219
- }
2220
- }
2221
- console.log(t("cli.login.timeout"));
2222
- process.exit(1);
2223
- }
2224
- };
2225
-
2226
- // src/cli/commands/logout.ts
2227
- var logoutCommand = {
2228
- command: "logout",
2229
- describe: "Logout from ReadShell Pro",
2230
- handler: () => {
2231
- clearAuth();
2232
- console.log(t("cli.logout.success"));
2233
- }
2234
- };
2235
-
2236
- // src/cli/commands/sync.ts
2237
- var syncCommand = {
2238
- command: "sync",
2239
- describe: "Sync reading progress and bookmarks to cloud",
2240
- handler: async () => {
2241
- if (!isLoggedIn()) {
2242
- console.log(t("cli.sync.not_logged_in"));
2243
- process.exit(1);
2244
- }
2245
- const tier = getAuthTier();
2246
- if (!tier || tier === "free") {
2247
- console.log(t("cli.sync.need_pro"));
2248
- process.exit(1);
2249
- }
2250
- const syncService = createSyncService();
2251
- if (!syncService) {
2252
- console.log(t("cli.sync.not_logged_in"));
2253
- process.exit(1);
2254
- }
2255
- console.log(t("cli.sync.running"));
2256
- try {
2257
- const result = await syncService.fullSync();
2258
- console.log(t("cli.sync.success", String(result.pushed), String(result.pulled)));
2259
- } catch (err) {
2260
- const msg = err instanceof Error ? err.message : String(err);
2261
- console.log(t("cli.sync.failed", msg));
2262
- process.exit(1);
2263
- }
2264
- }
2265
- };
2266
-
2267
- // src/cli/parser.ts
2268
- function createParser() {
2269
- const version = true ? "0.3.6" : "dev";
2270
- return yargs(hideBin(process.argv)).scriptName("novel").usage("$0 <command> [options]").command(importCommand).command(resumeCommand).command(openCommand).command(libraryCommand).command(removeCommand).command(langCommand).command(updateCommand).command(loginCommand).command(logoutCommand).command(syncCommand).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(version).epilogue("ReadShell Pro \u2014 \u7EC8\u7AEF\u5185\u4F4E\u6253\u65AD\u8F7B\u9605\u8BFB\u5DE5\u5177 + \u4E91\u540C\u6B65");
2271
- }
2272
-
2273
- // src/db/migrate.ts
2274
- var SCHEMA_VERSION = 2;
2275
- function initDatabase() {
2276
- const db2 = getDb();
2277
- db2.exec(`
2278
- CREATE TABLE IF NOT EXISTS schema_version (
2279
- version INTEGER PRIMARY KEY
2280
- );
2281
- `);
2282
- const row = db2.prepare("SELECT version FROM schema_version LIMIT 1").get();
2283
- const currentVersion = row?.version ?? 0;
2284
- if (currentVersion < SCHEMA_VERSION) {
2285
- logger.debug(`\u6570\u636E\u5E93\u8FC1\u79FB: v${currentVersion} \u2192 v${SCHEMA_VERSION}`);
2286
- migrate(db2, currentVersion);
2287
- }
2288
- }
2289
- function migrate(db2, fromVersion) {
2290
- const migrations = {
2291
- 1: `
2292
- -- \u4E66\u7C4D\u5143\u6570\u636E
2293
- CREATE TABLE IF NOT EXISTS books (
2294
- id TEXT PRIMARY KEY,
2295
- title TEXT NOT NULL,
2296
- author TEXT,
2297
- file_path TEXT NOT NULL,
2298
- format TEXT NOT NULL,
2299
- file_hash TEXT NOT NULL,
2300
- file_size INTEGER,
2301
- created_at INTEGER NOT NULL
2302
- );
2303
-
2304
- -- \u6838\u5FC3\u72B6\u6001\u8868
2305
- CREATE TABLE IF NOT EXISTS reading_progress (
2306
- book_id TEXT PRIMARY KEY REFERENCES books(id),
2307
- chapter_no INTEGER NOT NULL DEFAULT 0,
2308
- byte_offset INTEGER NOT NULL DEFAULT 0,
2309
- percent REAL NOT NULL DEFAULT 0,
2310
- updated_at INTEGER NOT NULL,
2311
- opened_at INTEGER NOT NULL
2312
- );
2313
-
2314
- -- \u6700\u8FD1\u9605\u8BFB\u6392\u5E8F
2315
- CREATE TABLE IF NOT EXISTS recent_reads (
2316
- book_id TEXT PRIMARY KEY REFERENCES books(id),
2317
- opened_at INTEGER NOT NULL,
2318
- open_count INTEGER NOT NULL DEFAULT 1
2319
- );
2320
-
2321
- -- \u7AE0\u8282\u7D22\u5F15
2322
- CREATE TABLE IF NOT EXISTS chapter_index (
2323
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2324
- book_id TEXT NOT NULL REFERENCES books(id),
2325
- chapter_no INTEGER NOT NULL,
2326
- title TEXT,
2327
- byte_offset INTEGER NOT NULL,
2328
- UNIQUE(book_id, chapter_no)
2329
- );
2330
-
2331
- -- \u7D22\u5F15
2332
- CREATE INDEX IF NOT EXISTS idx_chapter_book ON chapter_index(book_id);
2333
- CREATE INDEX IF NOT EXISTS idx_recent_opened ON recent_reads(opened_at DESC);
2334
- `,
2335
- 2: `
2336
- -- \u4E66\u7B7E\u7BA1\u7406
2337
- CREATE TABLE IF NOT EXISTS bookmarks (
2338
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2339
- book_id TEXT NOT NULL REFERENCES books(id),
2340
- title TEXT NOT NULL,
2341
- byte_offset INTEGER NOT NULL,
2342
- created_at INTEGER NOT NULL
2343
- );
2344
-
2345
- CREATE INDEX IF NOT EXISTS idx_bookmarks_book ON bookmarks(book_id);
2346
- `
2347
- };
2348
- db2.transaction(() => {
2349
- for (let v = fromVersion + 1; v <= SCHEMA_VERSION; v++) {
2350
- const sql = migrations[v];
2351
- if (sql) {
2352
- db2.exec(sql);
2353
- logger.debug(`\u5DF2\u6267\u884C\u8FC1\u79FB v${v}`);
2354
- }
2355
- }
2356
- db2.prepare("DELETE FROM schema_version").run();
2357
- db2.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION);
2358
- })();
2359
- }
2360
-
2361
- // src/index.ts
2362
- async function main() {
2363
- try {
2364
- initDatabase();
2365
- initI18n();
2366
- const parser = createParser();
2367
- await parser.parse();
2368
- } catch (error) {
2369
- logger.error("\u7A0B\u5E8F\u542F\u52A8\u5931\u8D25:", error);
2370
- process.exit(1);
2371
- }
2372
- }
2373
- main();
2374
- //# sourceMappingURL=index.js.map