readshell 0.3.1 → 1.0.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 CHANGED
@@ -6,7 +6,7 @@ import { hideBin } from "yargs/helpers";
6
6
 
7
7
  // src/services/BookService.ts
8
8
  import { resolve } from "path";
9
- import { existsSync as existsSync2, statSync } from "fs";
9
+ import { existsSync as existsSync3, statSync } from "fs";
10
10
  import { nanoid } from "nanoid";
11
11
 
12
12
  // src/db/client.ts
@@ -17,19 +17,24 @@ import { join } from "path";
17
17
  import { homedir } from "os";
18
18
  import { mkdirSync, existsSync } from "fs";
19
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");
20
+ const override = process.env["READSHELL_HOME"];
21
+ let configDir2;
22
+ if (override) {
23
+ configDir2 = override;
26
24
  } else {
27
- configDir = join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "readshell");
25
+ const platform = process.platform;
26
+ if (platform === "darwin") {
27
+ configDir2 = join(homedir(), "Library", "Application Support", "readshell");
28
+ } else if (platform === "win32") {
29
+ configDir2 = join(process.env["APPDATA"] || join(homedir(), "AppData", "Roaming"), "readshell");
30
+ } else {
31
+ configDir2 = join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "readshell");
32
+ }
28
33
  }
29
- if (!existsSync(configDir)) {
30
- mkdirSync(configDir, { recursive: true });
34
+ if (!existsSync(configDir2)) {
35
+ mkdirSync(configDir2, { recursive: true });
31
36
  }
32
- return configDir;
37
+ return configDir2;
33
38
  }
34
39
  function getDbPath() {
35
40
  return join(getAppDataDir(), "readshell.db");
@@ -123,6 +128,13 @@ var BookModel = class {
123
128
  const db2 = getDb();
124
129
  return db2.prepare("SELECT * FROM books ORDER BY created_at DESC").all();
125
130
  }
131
+ /**
132
+ * 更新书名/作者(文件夹同步导入后恢复远端元数据用)
133
+ */
134
+ updateMeta(id, title, author) {
135
+ const db2 = getDb();
136
+ db2.prepare("UPDATE books SET title = ?, author = ? WHERE id = ?").run(title, author, id);
137
+ }
126
138
  /**
127
139
  * 删除书籍
128
140
  */
@@ -253,6 +265,152 @@ var ProgressModel = class {
253
265
  }
254
266
  };
255
267
 
268
+ // src/db/models/Bookmark.ts
269
+ var BookmarkModel = class {
270
+ /**
271
+ * 插入书签
272
+ */
273
+ insert(bookmark) {
274
+ const db2 = getDb();
275
+ db2.prepare(`
276
+ INSERT INTO bookmarks (book_id, title, byte_offset, created_at, updated_at, deleted)
277
+ VALUES (?, ?, ?, ?, ?, ?)
278
+ `).run(
279
+ bookmark.book_id,
280
+ bookmark.title,
281
+ bookmark.byte_offset,
282
+ bookmark.created_at,
283
+ bookmark.updated_at,
284
+ bookmark.deleted
285
+ );
286
+ }
287
+ /**
288
+ * 获取指定书籍的所有有效书签(不含墓碑)
289
+ */
290
+ findByBookId(bookId) {
291
+ const db2 = getDb();
292
+ return db2.prepare("SELECT * FROM bookmarks WHERE book_id = ? AND deleted = 0 ORDER BY created_at DESC").all(bookId);
293
+ }
294
+ /**
295
+ * 获取指定书籍的全部书签记录(含墓碑,同步导出用)
296
+ */
297
+ findAllByBookId(bookId) {
298
+ const db2 = getDb();
299
+ return db2.prepare("SELECT * FROM bookmarks WHERE book_id = ? ORDER BY created_at DESC").all(bookId);
300
+ }
301
+ /**
302
+ * 按 (book_id, byte_offset) 查找有效书签
303
+ */
304
+ findByOffset(bookId, byteOffset) {
305
+ const db2 = getDb();
306
+ return db2.prepare("SELECT * FROM bookmarks WHERE book_id = ? AND byte_offset = ? AND deleted = 0").get(bookId, byteOffset);
307
+ }
308
+ /**
309
+ * 按 (book_id, byte_offset) 查找书签记录(含墓碑,同步合并用)
310
+ */
311
+ findByOffsetAny(bookId, byteOffset) {
312
+ const db2 = getDb();
313
+ return db2.prepare("SELECT * FROM bookmarks WHERE book_id = ? AND byte_offset = ?").get(bookId, byteOffset);
314
+ }
315
+ /**
316
+ * 按 (book_id, byte_offset) 软删除书签(写墓碑,同步传播删除用)
317
+ */
318
+ deleteByOffset(bookId, byteOffset) {
319
+ const db2 = getDb();
320
+ db2.prepare("UPDATE bookmarks SET deleted = 1, updated_at = ? WHERE book_id = ? AND byte_offset = ? AND deleted = 0").run(Date.now(), bookId, byteOffset);
321
+ }
322
+ /**
323
+ * 同步合并:按 (book_id, byte_offset) 覆盖写入一条书签记录(可为墓碑)
324
+ */
325
+ saveByOffset(record) {
326
+ const db2 = getDb();
327
+ const existing = this.findByOffsetAny(record.book_id, record.byte_offset);
328
+ if (existing?.id !== void 0) {
329
+ db2.prepare(`
330
+ UPDATE bookmarks SET title = ?, created_at = ?, updated_at = ?, deleted = ?
331
+ WHERE id = ?
332
+ `).run(record.title, record.created_at, record.updated_at, record.deleted, existing.id);
333
+ } else {
334
+ this.insert(record);
335
+ }
336
+ }
337
+ /**
338
+ * 获取指定书签
339
+ */
340
+ findById(id) {
341
+ const db2 = getDb();
342
+ return db2.prepare("SELECT * FROM bookmarks WHERE id = ?").get(id);
343
+ }
344
+ /**
345
+ * 获取书籍有效书签总数
346
+ */
347
+ getCount(bookId) {
348
+ const db2 = getDb();
349
+ const result = db2.prepare("SELECT COUNT(*) as count FROM bookmarks WHERE book_id = ? AND deleted = 0").get(bookId);
350
+ return result.count;
351
+ }
352
+ /**
353
+ * 删除书签(写墓碑)
354
+ */
355
+ delete(id) {
356
+ const db2 = getDb();
357
+ db2.prepare("UPDATE bookmarks SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0").run(Date.now(), id);
358
+ }
359
+ /**
360
+ * 移除整本书的书签 (配合彻底清理书籍使用,物理删除含墓碑)
361
+ */
362
+ deleteByBookId(bookId) {
363
+ const db2 = getDb();
364
+ db2.prepare("DELETE FROM bookmarks WHERE book_id = ?").run(bookId);
365
+ }
366
+ };
367
+
368
+ // src/db/models/ReadingSession.ts
369
+ var ReadingSessionModel = class {
370
+ /**
371
+ * 记录一次阅读会话
372
+ */
373
+ insert(session) {
374
+ const db2 = getDb();
375
+ db2.prepare(`
376
+ INSERT INTO reading_sessions (id, book_id, started_at, ended_at, bytes_read)
377
+ VALUES (?, ?, ?, ?, ?)
378
+ `).run(session.id, session.book_id, session.started_at, session.ended_at, session.bytes_read);
379
+ }
380
+ /**
381
+ * 按 id 合并远端会话(已存在则跳过)
382
+ */
383
+ insertIfAbsent(session) {
384
+ const db2 = getDb();
385
+ const result = db2.prepare(`
386
+ INSERT OR IGNORE INTO reading_sessions (id, book_id, started_at, ended_at, bytes_read)
387
+ VALUES (?, ?, ?, ?, ?)
388
+ `).run(session.id, session.book_id, session.started_at, session.ended_at, session.bytes_read);
389
+ return result.changes > 0;
390
+ }
391
+ /**
392
+ * 获取指定书籍的全部会话
393
+ */
394
+ findByBookId(bookId) {
395
+ const db2 = getDb();
396
+ return db2.prepare("SELECT * FROM reading_sessions WHERE book_id = ? ORDER BY started_at ASC").all(bookId);
397
+ }
398
+ /**
399
+ * 获取全部会话(统计与同步导出用)
400
+ */
401
+ findAll() {
402
+ const db2 = getDb();
403
+ return db2.prepare("SELECT * FROM reading_sessions ORDER BY started_at ASC").all();
404
+ }
405
+ /**
406
+ * 删除指定书籍的全部会话(配合彻底清理书籍使用)
407
+ */
408
+ deleteByBookId(bookId) {
409
+ const db2 = getDb();
410
+ db2.prepare("DELETE FROM reading_sessions WHERE book_id = ?").run(bookId);
411
+ }
412
+ };
413
+
256
414
  // src/parsers/TxtParser.ts
257
415
  import { readFileSync } from "fs";
258
416
  import { detect } from "chardet";
@@ -279,7 +437,7 @@ async function parseTxt(filePath) {
279
437
  }
280
438
  function extractTitle(filePath) {
281
439
  const basename = filePath.split("/").pop() || filePath;
282
- return basename.replace(/\.txt$/i, "").trim() || "\u672A\u547D\u540D";
440
+ return basename.replace(/\.(txt|md)$/i, "").trim() || "\u672A\u547D\u540D";
283
441
  }
284
442
  function extractChapters(content) {
285
443
  const chapters = [];
@@ -353,10 +511,10 @@ ${plainText}
353
511
  };
354
512
  }
355
513
  function openEpub(filePath) {
356
- return new Promise((resolve3, reject) => {
514
+ return new Promise((resolve5, reject) => {
357
515
  const epub = new EPub(filePath);
358
516
  epub.on("error", (err) => reject(err));
359
- epub.on("end", () => resolve3(epub));
517
+ epub.on("end", () => resolve5(epub));
360
518
  epub.parse();
361
519
  });
362
520
  }
@@ -372,13 +530,13 @@ function detectHtmlEncoding(buffer) {
372
530
  return chardetResult || "utf-8";
373
531
  }
374
532
  function getChapterHtml(epub, chapterId) {
375
- return new Promise((resolve3, reject) => {
533
+ return new Promise((resolve5, reject) => {
376
534
  epub.getFile(chapterId, (err, data) => {
377
535
  if (err) return reject(err);
378
536
  const buffer = data;
379
537
  const encoding = detectHtmlEncoding(buffer);
380
538
  const htmlText = encoding.toLowerCase().replace("-", "") === "utf8" || encoding.toLowerCase() === "utf-8" ? buffer.toString("utf-8") : iconv2.decode(buffer, encoding);
381
- resolve3(htmlText);
539
+ resolve5(htmlText);
382
540
  });
383
541
  });
384
542
  }
@@ -387,6 +545,7 @@ function getChapterHtml(epub, chapterId) {
387
545
  async function parseFile(filePath, format) {
388
546
  switch (format) {
389
547
  case "txt":
548
+ case "md":
390
549
  return parseTxt(filePath);
391
550
  case "epub":
392
551
  return parseEpub(filePath);
@@ -405,97 +564,6 @@ async function computeFileHash(filePath) {
405
564
  return hash.digest("hex");
406
565
  }
407
566
 
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
567
  // src/locales/zh.ts
500
568
  var zh_default = {
501
569
  // Common
@@ -506,11 +574,11 @@ var zh_default = {
506
574
  "common.quit": "\u6309 q \u9000\u51FA",
507
575
  // CLI
508
576
  "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",
577
+ "cli.import.help": "\u6587\u4EF6\u6216\u76EE\u5F55\u8DEF\u5F84\uFF08\u652F\u6301 .txt / .epub / .md\uFF09",
510
578
  "cli.import.success": "\u2713 \u5DF2\u5BFC\u5165:",
511
579
  "cli.import.fail": "\u5BFC\u5165\u5931\u8D25:",
512
580
  "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",
581
+ "cli.import.unsupported": "\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u683C\u5F0F\u3002\u76EE\u524D\u652F\u6301: .txt, .epub, .md",
514
582
  "cli.import.scan_dir": "\u626B\u63CF\u76EE\u5F55",
515
583
  "cli.import.found_files": "\u627E\u5230\u4EE5\u4E0B\u4E66\u7C4D\uFF1A",
516
584
  "cli.import.confirm_batch": "\u662F\u5426\u786E\u8BA4\u5BFC\u5165\u4E0A\u8FF0 {0} \u672C\u4E66\uFF1F(y/N)",
@@ -530,6 +598,8 @@ var zh_default = {
530
598
  "cli.remove.not_found": "\u2717 \u672A\u627E\u5230\u5339\u914D\u4E66\u7C4D:",
531
599
  "cli.remove.success": "\u2713 \u5DF2\u79FB\u9664\u4E66\u7C4D:",
532
600
  "cli.remove.fail": "\u79FB\u9664\u4E66\u7C4D\u5931\u8D25:",
601
+ "cli.lang.desc": "\u5207\u6362\u754C\u9762\u8BED\u8A00",
602
+ "cli.lang.help": "\u76EE\u6807\u8BED\u8A00\uFF08zh \u4E2D\u6587 | en \u82F1\u6587\uFF09",
533
603
  "cli.lang.success": "\u2713 \u8BED\u8A00\u5DF2\u5207\u6362\u4E3A: {0}",
534
604
  "cli.lang.unsupported": "\u2717 \u4E0D\u652F\u6301\u7684\u8BED\u8A00: {0}",
535
605
  "cli.config.desc": "\u4FEE\u6539\u5E94\u7528\u914D\u7F6E",
@@ -557,7 +627,37 @@ var zh_default = {
557
627
  "tui.nav.tips": "Enter \u8DF3\u8F6C \xB7 Tab \u5207\u6362 \xB7 Esc/q \u5173\u95ED",
558
628
  "tui.nav.hint": "\u6309 C \u952E\u5F39\u51FA\u6B64\u6E05\u5355\uFF0C\u6309 Tab \u5207\u6362\u4E66\u7B7E",
559
629
  "tui.nav.empty": "\u6CA1\u6709\u8BB0\u5F55",
560
- "tui.nav.page": "\u7B2C {0} / {1} \u9875"
630
+ "tui.nav.page": "\u7B2C {0} / {1} \u9875",
631
+ // TUI - Help
632
+ "tui.help.title": "\u5FEB\u6377\u952E",
633
+ "tui.help.next": " Space / j / \u2193 / f \u4E0B\u4E00\u9875",
634
+ "tui.help.prev": " k / \u2191 \u4E0A\u4E00\u9875 \xB7 g \u9996\u9875 \xB7 G \u672B\u9875",
635
+ "tui.help.nav": " c \u7AE0\u8282\u4E0E\u4E66\u7B7E \xB7 Tab \u5207\u6362 \xB7 m \u52A0\u4E66\u7B7E",
636
+ "tui.help.boss": " b / Esc \u8001\u677F\u952E \xB7 q \u9000\u51FA \xB7 ? \u5E2E\u52A9",
637
+ // Sync / Stats / Bookmarks
638
+ "cli.sync.desc": "\u901A\u8FC7\u540C\u6B65\u6587\u4EF6\u5939\u5728\u591A\u8BBE\u5907\u95F4\u540C\u6B65\u8FDB\u5EA6\u3001\u4E66\u7B7E\u4E0E\u9605\u8BFB\u7EDF\u8BA1",
639
+ "cli.sync.dir": "\u540C\u6B65\u76EE\u5F55\u8DEF\u5F84\uFF08\u5982 iCloud Drive / Dropbox / \u575A\u679C\u4E91 \u5185\u7684\u6587\u4EF6\u5939\uFF09",
640
+ "cli.sync.with_books": "\u540C\u65F6\u540C\u6B65\u4E66\u6E90\u6587\u4EF6\uFF08\u5176\u4ED6\u8BBE\u5907\u53EF\u81EA\u52A8\u5BFC\u5165\u7F3A\u5931\u7684\u4E66\uFF09",
641
+ "cli.sync.off": "\u5173\u95ED\u6587\u4EF6\u5939\u540C\u6B65",
642
+ "cli.sync.disabled": "\u2713 \u5DF2\u5173\u95ED\u6587\u4EF6\u5939\u540C\u6B65",
643
+ "cli.sync.dir_set": "\u2713 \u540C\u6B65\u76EE\u5F55\u5DF2\u8BBE\u7F6E: {0}",
644
+ "cli.sync.dir_invalid": "\u2717 \u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u662F\u6587\u4EF6\u5939: {0}",
645
+ "cli.sync.no_dir": "\u5C1A\u672A\u8BBE\u7F6E\u540C\u6B65\u76EE\u5F55\u3002\u8BF7\u5148\u8FD0\u884C: novel sync --dir <\u8DEF\u5F84>",
646
+ "cli.sync.running": "\u540C\u6B65\u4E2D...",
647
+ "cli.sync.success": "\u2713 \u540C\u6B65\u5B8C\u6210\uFF08\u5408\u5E76 {0} \u6761\uFF0C\u6765\u81EA {1} \u53F0\u8BBE\u5907\uFF09",
648
+ "cli.sync.failed": "\u540C\u6B65\u5931\u8D25\uFF1A{0}",
649
+ "cli.stats.desc": "\u9605\u8BFB\u7EDF\u8BA1\uFF1A\u70ED\u529B\u56FE\u4E0E\u8FDE\u7EED\u5929\u6570",
650
+ "cli.stats.title": "\u{1F4CA} \u9605\u8BFB\u7EDF\u8BA1",
651
+ "cli.stats.less": "\u5C11",
652
+ "cli.stats.more": "\u591A",
653
+ "cli.stats.summary": "\u9605\u8BFB\u5929\u6570 {0} \xB7 \u603B\u65F6\u957F {1} \u5206\u949F \xB7 \u8BFB\u8FC7 {2} \u672C \xB7 \u5F53\u524D\u8FDE\u7EED {3} \u5929 \xB7 \u6700\u957F\u8FDE\u7EED {4} \u5929",
654
+ "cli.bookmarks.desc": "\u4E66\u7B7E\u7BA1\u7406",
655
+ "cli.bookmarks.export.desc": "\u5BFC\u51FA\u5168\u90E8\u4E66\u7B7E",
656
+ "cli.bookmarks.export.format": "\u8F93\u51FA\u683C\u5F0F: json | md",
657
+ "cli.bookmarks.export.out": "\u8F93\u51FA\u6587\u4EF6\u8DEF\u5F84\uFF08\u9ED8\u8BA4\u6253\u5370\u5230\u7EC8\u7AEF\uFF09",
658
+ "cli.bookmarks.export.empty": "\u6CA1\u6709\u4E66\u7B7E\u53EF\u5BFC\u51FA",
659
+ "cli.bookmarks.export.success": "\u2713 \u5DF2\u5BFC\u51FA {0} \u6761\u4E66\u7B7E\u5230 {1}",
660
+ "cli.bookmarks.export.fail": "\u5BFC\u51FA\u4E66\u7B7E\u5931\u8D25: {0}"
561
661
  };
562
662
 
563
663
  // src/locales/en.ts
@@ -570,11 +670,11 @@ var en = {
570
670
  "common.quit": "Press q to quit",
571
671
  // CLI
572
672
  "cli.import.desc": "Import local file or directory to library",
573
- "cli.import.help": "File or directory path (supports .txt / .epub)",
673
+ "cli.import.help": "File or directory path (supports .txt / .epub / .md)",
574
674
  "cli.import.success": "\u2713 Imported:",
575
675
  "cli.import.fail": "Import failed:",
576
676
  "cli.import.not_found": "Path not found:",
577
- "cli.import.unsupported": "Unsupported file format. Currently supports: .txt, .epub",
677
+ "cli.import.unsupported": "Unsupported file format. Currently supports: .txt, .epub, .md",
578
678
  "cli.import.scan_dir": "Scanning directory",
579
679
  "cli.import.found_files": "Found following books:",
580
680
  "cli.import.confirm_batch": "Confirm importing these {0} books? (y/N)",
@@ -594,6 +694,8 @@ var en = {
594
694
  "cli.remove.not_found": "\u2717 Book not found:",
595
695
  "cli.remove.success": "\u2713 Book removed:",
596
696
  "cli.remove.fail": "Failed to remove book:",
697
+ "cli.lang.desc": "Switch interface language",
698
+ "cli.lang.help": "Target language (zh Chinese | en English)",
597
699
  "cli.lang.success": "\u2713 Language switched to: {0}",
598
700
  "cli.lang.unsupported": "\u2717 Unsupported language: {0}",
599
701
  "cli.config.desc": "Modify application configuration",
@@ -622,35 +724,123 @@ var en = {
622
724
  "tui.nav.tips": "Enter jump \xB7 Tab switch \xB7 Esc/q close",
623
725
  "tui.nav.hint": "Press C to open this list, press Tab to switch to bookmarks",
624
726
  "tui.nav.empty": "No records",
625
- "tui.nav.page": "Page {0} / {1}"
727
+ "tui.nav.page": "Page {0} / {1}",
728
+ // TUI - Help
729
+ "tui.help.title": "Keyboard shortcuts",
730
+ "tui.help.next": " Space / j / \u2193 / f next page",
731
+ "tui.help.prev": " k / \u2191 prev page \xB7 g first \xB7 G last",
732
+ "tui.help.nav": " c chapters & bookmarks \xB7 Tab switch \xB7 m bookmark",
733
+ "tui.help.boss": " b / Esc boss key \xB7 q quit \xB7 ? help",
734
+ // Sync / Stats / Bookmarks
735
+ "cli.sync.desc": "Sync progress, bookmarks and reading stats across devices via a synced folder",
736
+ "cli.sync.dir": "Sync folder path (e.g. a folder inside iCloud Drive / Dropbox / Syncthing)",
737
+ "cli.sync.with_books": "Also sync book files (other devices auto-import missing books)",
738
+ "cli.sync.off": "Disable folder sync",
739
+ "cli.sync.disabled": "\u2713 Folder sync disabled",
740
+ "cli.sync.dir_set": "\u2713 Sync folder set: {0}",
741
+ "cli.sync.dir_invalid": "\u2717 Path does not exist or is not a directory: {0}",
742
+ "cli.sync.no_dir": "No sync folder configured. Run: novel sync --dir <path>",
743
+ "cli.sync.running": "Syncing...",
744
+ "cli.sync.success": "\u2713 Sync complete (merged {0} items from {1} devices)",
745
+ "cli.sync.failed": "Sync failed: {0}",
746
+ "cli.stats.desc": "Reading stats: heatmap and streaks",
747
+ "cli.stats.title": "\u{1F4CA} Reading Stats",
748
+ "cli.stats.less": "Less",
749
+ "cli.stats.more": "More",
750
+ "cli.stats.summary": "{0} reading days \xB7 {1} minutes total \xB7 {2} books \xB7 current streak {3}d \xB7 longest {4}d",
751
+ "cli.bookmarks.desc": "Manage bookmarks",
752
+ "cli.bookmarks.export.desc": "Export all bookmarks",
753
+ "cli.bookmarks.export.format": "Output format: json | md",
754
+ "cli.bookmarks.export.out": "Output file path (defaults to stdout)",
755
+ "cli.bookmarks.export.empty": "No bookmarks to export",
756
+ "cli.bookmarks.export.success": "\u2713 Exported {0} bookmarks to {1}",
757
+ "cli.bookmarks.export.fail": "Failed to export bookmarks: {0}"
626
758
  };
627
759
  var en_default = en;
628
760
 
629
761
  // src/config/AppConfig.ts
630
762
  import Conf from "conf";
763
+ import { randomUUID } from "crypto";
764
+ import { existsSync as existsSync2, copyFileSync } from "fs";
765
+ import { join as join2 } from "path";
766
+ import { homedir as homedir2 } from "os";
631
767
  var defaults = {
632
768
  linesPerPage: 0,
633
769
  showStatusBar: true,
634
770
  readingMode: "page",
635
771
  language: "zh",
636
- lineSpacing: 0
772
+ lineSpacing: 0,
773
+ bossKeyLang: "nodejs"
637
774
  };
775
+ var configDir = getAppDataDir();
776
+ function legacyConfigPath() {
777
+ const name = "readshell-nodejs";
778
+ if (process.platform === "darwin") {
779
+ return join2(homedir2(), "Library", "Preferences", name, "config.json");
780
+ }
781
+ if (process.platform === "win32") {
782
+ const appData = process.env["APPDATA"] || join2(homedir2(), "AppData", "Roaming");
783
+ return join2(appData, name, "Config", "config.json");
784
+ }
785
+ const xdgConfig = process.env["XDG_CONFIG_HOME"] || join2(homedir2(), ".config");
786
+ return join2(xdgConfig, name, "config.json");
787
+ }
788
+ var newConfigPath = join2(configDir, "config.json");
789
+ if (!process.env["READSHELL_HOME"] && !existsSync2(newConfigPath)) {
790
+ const legacyPath = legacyConfigPath();
791
+ if (existsSync2(legacyPath)) {
792
+ try {
793
+ copyFileSync(legacyPath, newConfigPath);
794
+ } catch {
795
+ }
796
+ }
797
+ }
638
798
  var config = new Conf({
639
- projectName: "readshell",
799
+ cwd: configDir,
640
800
  defaults
641
801
  });
802
+ for (const legacyKey of [
803
+ "authToken",
804
+ "authUserId",
805
+ "authDeviceId",
806
+ "authTier",
807
+ "authExpiresAt",
808
+ "lastSyncAt"
809
+ ]) {
810
+ config.delete(legacyKey);
811
+ }
642
812
  function getConfig() {
643
813
  return {
644
814
  linesPerPage: config.get("linesPerPage"),
645
815
  showStatusBar: config.get("showStatusBar"),
646
816
  readingMode: config.get("readingMode"),
647
817
  language: config.get("language"),
648
- lineSpacing: config.get("lineSpacing")
818
+ lineSpacing: config.get("lineSpacing"),
819
+ bossKeyLang: config.get("bossKeyLang"),
820
+ deviceId: config.get("deviceId"),
821
+ syncDir: config.get("syncDir")
649
822
  };
650
823
  }
651
824
  function setConfig(key, value) {
652
825
  config.set(key, value);
653
826
  }
827
+ function getDeviceId() {
828
+ let id = config.get("deviceId");
829
+ if (!id) {
830
+ id = randomUUID();
831
+ config.set("deviceId", id);
832
+ }
833
+ return id;
834
+ }
835
+ function getSyncDir() {
836
+ return config.get("syncDir") ?? null;
837
+ }
838
+ function setSyncDir(dir) {
839
+ config.set("syncDir", dir);
840
+ }
841
+ function clearSyncDir() {
842
+ config.delete("syncDir");
843
+ }
654
844
 
655
845
  // src/locales/index.ts
656
846
  var dictionaries = {
@@ -678,25 +868,119 @@ function t(key, ...args) {
678
868
  template = template.replace(`{${index}}`, String(arg));
679
869
  });
680
870
  }
681
- return template;
682
- }
871
+ return template;
872
+ }
873
+
874
+ // src/services/BookService.ts
875
+ var BookService = class {
876
+ bookModel = new BookModel();
877
+ chapterModel = new ChapterModel();
878
+ recentModel = new RecentModel();
879
+ progressModel = new ProgressModel();
880
+ /**
881
+ * 导入书籍文件
882
+ */
883
+ async importBook(filePath) {
884
+ const absPath = resolve(filePath);
885
+ if (!existsSync3(absPath)) {
886
+ throw new Error(`\u6587\u4EF6\u4E0D\u5B58\u5728: ${absPath}`);
887
+ }
888
+ const format = this.detectFormat(absPath);
889
+ if (!format) {
890
+ throw new Error(t("cli.import.unsupported"));
891
+ }
892
+ const fileHash = await computeFileHash(absPath);
893
+ const existing = this.bookModel.findByHash(fileHash);
894
+ if (existing) {
895
+ logger.debug(`\u6587\u4EF6\u5DF2\u5B58\u5728: ${existing.title} (${existing.id})`);
896
+ return existing;
897
+ }
898
+ const parsed = await parseFile(absPath, format);
899
+ const stats = statSync(absPath);
900
+ const book = {
901
+ id: nanoid(),
902
+ title: parsed.title,
903
+ author: parsed.author || null,
904
+ file_path: absPath,
905
+ format,
906
+ file_hash: fileHash,
907
+ file_size: stats.size,
908
+ created_at: Date.now()
909
+ };
910
+ this.bookModel.insert(book);
911
+ if (parsed.chapters.length > 0) {
912
+ this.chapterModel.insertMany(
913
+ parsed.chapters.map((ch, idx) => ({
914
+ book_id: book.id,
915
+ chapter_no: idx,
916
+ title: ch.title,
917
+ byte_offset: ch.byteOffset
918
+ }))
919
+ );
920
+ }
921
+ logger.debug(`\u5BFC\u5165\u6210\u529F: ${book.title}`);
922
+ return book;
923
+ }
924
+ /**
925
+ * 查找书籍(ID 或模糊匹配书名)
926
+ */
927
+ findBook(target) {
928
+ const byId = this.bookModel.findById(target);
929
+ if (byId) return byId;
930
+ const results = this.bookModel.searchByTitle(target);
931
+ return results[0];
932
+ }
933
+ /**
934
+ * 搜索书籍
935
+ */
936
+ searchBooks(keyword) {
937
+ return this.bookModel.searchByTitle(keyword);
938
+ }
939
+ /**
940
+ * 获取所有书籍
941
+ */
942
+ getAllBooks() {
943
+ return this.bookModel.findAll();
944
+ }
945
+ /**
946
+ * 删除书籍及相关数据
947
+ */
948
+ deleteBook(id) {
949
+ this.chapterModel.deleteByBookId(id);
950
+ this.progressModel.delete(id);
951
+ this.recentModel.delete(id);
952
+ new BookmarkModel().deleteByBookId(id);
953
+ new ReadingSessionModel().deleteByBookId(id);
954
+ this.bookModel.delete(id);
955
+ }
956
+ /**
957
+ * 检测文件格式
958
+ */
959
+ detectFormat(filePath) {
960
+ const ext = filePath.toLowerCase().split(".").pop();
961
+ if (ext === "txt") return "txt";
962
+ if (ext === "epub") return "epub";
963
+ if (ext === "md") return "md";
964
+ return null;
965
+ }
966
+ };
683
967
 
684
968
  // src/cli/commands/import.ts
685
969
  import { statSync as statSync2, readdirSync } from "fs";
686
- import { resolve as resolve2, join as join2, extname } from "path";
970
+ import { resolve as resolve2, join as join3, extname } from "path";
687
971
  import * as readline from "readline/promises";
688
972
  function scanDirectory(dir) {
689
973
  let results = [];
690
974
  try {
691
975
  const list = readdirSync(dir);
692
976
  for (const file of list) {
693
- const fullPath = join2(dir, file);
977
+ const fullPath = join3(dir, file);
694
978
  const stat = statSync2(fullPath);
695
979
  if (stat.isDirectory()) {
696
980
  results = results.concat(scanDirectory(fullPath));
697
981
  } else {
698
982
  const ext = extname(fullPath).toLowerCase();
699
- if (ext === ".txt" || ext === ".epub") {
983
+ if (ext === ".txt" || ext === ".epub" || ext === ".md") {
700
984
  results.push(fullPath);
701
985
  }
702
986
  }
@@ -810,24 +1094,332 @@ import { Box as Box7, Text as Text7 } from "ink";
810
1094
  // src/ui/pages/ResumePage.tsx
811
1095
  import { useEffect, useState } from "react";
812
1096
  import { Box, Text, useApp } from "ink";
1097
+
1098
+ // src/services/SyncFolderService.ts
1099
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, writeFileSync, renameSync, copyFileSync as copyFileSync2 } from "fs";
1100
+ import { join as join4 } from "path";
1101
+ import { hostname } from "os";
1102
+ var SYNC_ROOT = "readshell-sync";
1103
+ var SCHEMA_VERSION = 1;
1104
+ function isValidDeviceFile(data) {
1105
+ if (typeof data !== "object" || data === null) return false;
1106
+ const d = data;
1107
+ return typeof d["deviceId"] === "string" && Array.isArray(d["books"]) && Array.isArray(d["progress"]) && Array.isArray(d["bookmarks"]) && Array.isArray(d["sessions"]);
1108
+ }
1109
+ function toMillis(iso) {
1110
+ if (typeof iso !== "string") return null;
1111
+ const ms = new Date(iso).getTime();
1112
+ return Number.isNaN(ms) ? null : ms;
1113
+ }
1114
+ function mergeRemoteDevice(remote, deps) {
1115
+ let count = 0;
1116
+ for (const p of remote.progress) {
1117
+ if (typeof p?.bookId !== "string") continue;
1118
+ const book = deps.findBookByHash(p.bookId);
1119
+ if (!book) continue;
1120
+ const remoteTs = toMillis(p.clientUpdatedAt);
1121
+ if (remoteTs === null) continue;
1122
+ const local = deps.findProgress(book.id);
1123
+ if (local && remoteTs <= local.updated_at) continue;
1124
+ deps.upsertProgress({
1125
+ book_id: book.id,
1126
+ chapter_no: local?.chapter_no ?? 0,
1127
+ byte_offset: p.byteOffset,
1128
+ percent: p.percentage,
1129
+ updated_at: remoteTs,
1130
+ // opened_at 只在远端更新时才前移
1131
+ opened_at: Math.max(local?.opened_at ?? 0, remoteTs)
1132
+ });
1133
+ count++;
1134
+ }
1135
+ for (const b of remote.bookmarks) {
1136
+ if (typeof b?.bookId !== "string" || typeof b?.byteOffset !== "number") continue;
1137
+ const book = deps.findBookByHash(b.bookId);
1138
+ if (!book) continue;
1139
+ const remoteTs = toMillis(b.updatedAt) ?? toMillis(b.createdAt);
1140
+ if (remoteTs === null) continue;
1141
+ const local = deps.findBookmarkByOffset(book.id, b.byteOffset);
1142
+ if (local && remoteTs <= local.updated_at) continue;
1143
+ const createdAt = toMillis(b.createdAt) ?? remoteTs;
1144
+ deps.saveBookmark({
1145
+ book_id: book.id,
1146
+ title: b.label ?? "",
1147
+ byte_offset: b.byteOffset,
1148
+ created_at: createdAt,
1149
+ updated_at: remoteTs,
1150
+ deleted: b.deleted ? 1 : 0
1151
+ });
1152
+ count++;
1153
+ }
1154
+ for (const s of remote.sessions) {
1155
+ if (typeof s?.id !== "string" || typeof s?.bookId !== "string") continue;
1156
+ const book = deps.findBookByHash(s.bookId);
1157
+ if (!book) continue;
1158
+ const startedAt = toMillis(s.startedAt);
1159
+ const endedAt = toMillis(s.endedAt);
1160
+ if (startedAt === null || endedAt === null) continue;
1161
+ if (deps.insertSession({
1162
+ id: s.id,
1163
+ book_id: book.id,
1164
+ started_at: startedAt,
1165
+ ended_at: endedAt,
1166
+ bytes_read: typeof s.bytesRead === "number" ? s.bytesRead : 0
1167
+ })) {
1168
+ count++;
1169
+ }
1170
+ }
1171
+ return count;
1172
+ }
1173
+ var SyncFolderService = class {
1174
+ constructor(syncDir) {
1175
+ this.syncDir = syncDir;
1176
+ }
1177
+ deviceId = getDeviceId();
1178
+ get rootDir() {
1179
+ return join4(this.syncDir, SYNC_ROOT);
1180
+ }
1181
+ get devicesDir() {
1182
+ return join4(this.rootDir, "devices");
1183
+ }
1184
+ get booksDir() {
1185
+ return join4(this.rootDir, "books");
1186
+ }
1187
+ get ownFile() {
1188
+ return join4(this.devicesDir, `${this.deviceId}.json`);
1189
+ }
1190
+ /**
1191
+ * 收集本机快照
1192
+ */
1193
+ collectLocal() {
1194
+ const bookModel = new BookModel();
1195
+ const progressModel = new ProgressModel();
1196
+ const bookmarkModel = new BookmarkModel();
1197
+ const sessionModel = new ReadingSessionModel();
1198
+ const books = [];
1199
+ const progress = [];
1200
+ const bookmarks = [];
1201
+ for (const book of bookModel.findAll()) {
1202
+ books.push({
1203
+ hash: book.file_hash,
1204
+ title: book.title,
1205
+ author: book.author,
1206
+ format: book.format,
1207
+ size: book.file_size
1208
+ });
1209
+ const p = progressModel.findByBookId(book.id);
1210
+ if (p) {
1211
+ progress.push({
1212
+ bookId: book.file_hash,
1213
+ byteOffset: p.byte_offset,
1214
+ percentage: p.percent,
1215
+ clientUpdatedAt: new Date(p.updated_at).toISOString()
1216
+ });
1217
+ }
1218
+ for (const b of bookmarkModel.findAllByBookId(book.id)) {
1219
+ bookmarks.push({
1220
+ id: `${book.file_hash}:${b.byte_offset}`,
1221
+ bookId: book.file_hash,
1222
+ byteOffset: b.byte_offset,
1223
+ label: b.title,
1224
+ deleted: b.deleted === 1,
1225
+ updatedAt: new Date(b.updated_at || b.created_at).toISOString(),
1226
+ createdAt: new Date(b.created_at).toISOString()
1227
+ });
1228
+ }
1229
+ }
1230
+ const sessions = [];
1231
+ for (const s of sessionModel.findAll()) {
1232
+ const book = bookModel.findById(s.book_id);
1233
+ if (!book) continue;
1234
+ sessions.push({
1235
+ id: s.id,
1236
+ bookId: book.file_hash,
1237
+ startedAt: new Date(s.started_at).toISOString(),
1238
+ endedAt: new Date(s.ended_at).toISOString(),
1239
+ bytesRead: s.bytes_read
1240
+ });
1241
+ }
1242
+ return {
1243
+ schemaVersion: SCHEMA_VERSION,
1244
+ deviceId: this.deviceId,
1245
+ deviceName: hostname(),
1246
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1247
+ books,
1248
+ progress,
1249
+ bookmarks,
1250
+ sessions
1251
+ };
1252
+ }
1253
+ /**
1254
+ * 原子写入本机设备文件(tmp + rename,避免同步盘读到半截文件)
1255
+ */
1256
+ writeOwnFile() {
1257
+ mkdirSync2(this.devicesDir, { recursive: true });
1258
+ const data = this.collectLocal();
1259
+ const tmp = `${this.ownFile}.tmp`;
1260
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
1261
+ renameSync(tmp, this.ownFile);
1262
+ }
1263
+ /**
1264
+ * 读取所有其他设备文件;损坏/半截 JSON 跳过并记 debug 日志
1265
+ */
1266
+ readRemoteFiles() {
1267
+ const files = [];
1268
+ let skipped = 0;
1269
+ let names = [];
1270
+ try {
1271
+ names = readdirSync2(this.devicesDir);
1272
+ } catch {
1273
+ return { files, skipped };
1274
+ }
1275
+ for (const name of names) {
1276
+ if (!name.endsWith(".json") || name === `${this.deviceId}.json`) continue;
1277
+ try {
1278
+ const parsed = JSON.parse(readFileSync3(join4(this.devicesDir, name), "utf-8"));
1279
+ if (!isValidDeviceFile(parsed)) {
1280
+ skipped++;
1281
+ logger.debug(`\u540C\u6B65\uFF1A\u8DF3\u8FC7\u683C\u5F0F\u975E\u6CD5\u7684\u8BBE\u5907\u6587\u4EF6 ${name}`);
1282
+ continue;
1283
+ }
1284
+ files.push(parsed);
1285
+ } catch (err) {
1286
+ skipped++;
1287
+ logger.debug(`\u540C\u6B65\uFF1A\u8DF3\u8FC7\u635F\u574F\u7684\u8BBE\u5907\u6587\u4EF6 ${name}: ${err instanceof Error ? err.message : String(err)}`);
1288
+ }
1289
+ }
1290
+ return { files, skipped };
1291
+ }
1292
+ mergeDeps() {
1293
+ const progressModel = new ProgressModel();
1294
+ const bookmarkModel = new BookmarkModel();
1295
+ const bookModel = new BookModel();
1296
+ const sessionModel = new ReadingSessionModel();
1297
+ return {
1298
+ findBookByHash: (hash) => bookModel.findByHash(hash),
1299
+ findProgress: (bookId) => progressModel.findByBookId(bookId),
1300
+ upsertProgress: (p) => progressModel.upsert(p),
1301
+ findBookmarkByOffset: (bookId, offset) => bookmarkModel.findByOffsetAny(bookId, offset),
1302
+ saveBookmark: (b) => bookmarkModel.saveByOffset(b),
1303
+ insertSession: (s) => sessionModel.insertIfAbsent(s)
1304
+ };
1305
+ }
1306
+ /**
1307
+ * 把本地书源文件拷进 books/(--with-books),已存在则跳过
1308
+ */
1309
+ pushBookFiles() {
1310
+ const bookModel = new BookModel();
1311
+ mkdirSync2(this.booksDir, { recursive: true });
1312
+ for (const book of bookModel.findAll()) {
1313
+ const target = join4(this.booksDir, `${book.file_hash}.${book.format}`);
1314
+ if (existsSync4(target)) continue;
1315
+ try {
1316
+ if (existsSync4(book.file_path)) {
1317
+ copyFileSync2(book.file_path, target);
1318
+ }
1319
+ } catch (err) {
1320
+ logger.debug(`\u540C\u6B65\uFF1A\u62F7\u8D1D\u4E66\u6E90\u6587\u4EF6\u5931\u8D25 ${book.title}: ${err instanceof Error ? err.message : String(err)}`);
1321
+ }
1322
+ }
1323
+ }
1324
+ /**
1325
+ * 远端有记录但本地没有的书,从 books/ 目录自动导入(--with-books)
1326
+ * 返回导入数量
1327
+ */
1328
+ async pullBookFiles(remoteFiles) {
1329
+ const bookModel = new BookModel();
1330
+ const bookService = new BookService();
1331
+ let imported = 0;
1332
+ const wanted = /* @__PURE__ */ new Map();
1333
+ for (const remote of remoteFiles) {
1334
+ for (const b of remote.books) {
1335
+ if (typeof b?.hash === "string" && !wanted.has(b.hash)) {
1336
+ wanted.set(b.hash, b);
1337
+ }
1338
+ }
1339
+ }
1340
+ for (const [hash, entry] of wanted) {
1341
+ if (bookModel.findByHash(hash)) continue;
1342
+ const ext = entry.format || "txt";
1343
+ const candidate = join4(this.booksDir, `${hash}.${ext}`);
1344
+ if (!existsSync4(candidate)) continue;
1345
+ try {
1346
+ const actual = await computeFileHash(candidate);
1347
+ if (actual !== hash) {
1348
+ logger.debug(`\u540C\u6B65\uFF1Abooks/${hash}.${ext} hash \u4E0D\u5339\u914D\uFF0C\u8DF3\u8FC7`);
1349
+ continue;
1350
+ }
1351
+ const book = await bookService.importBook(candidate);
1352
+ bookModel.updateMeta(book.id, entry.title, entry.author);
1353
+ imported++;
1354
+ } catch (err) {
1355
+ logger.debug(`\u540C\u6B65\uFF1A\u5BFC\u5165 ${candidate} \u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`);
1356
+ }
1357
+ }
1358
+ return imported;
1359
+ }
1360
+ /**
1361
+ * 只拉取:读取其他设备文件并合并(阅读器打开时调用)
1362
+ */
1363
+ async pull(options = {}) {
1364
+ const { files, skipped } = this.readRemoteFiles();
1365
+ let pulled = 0;
1366
+ if (options.withBooks) {
1367
+ pulled += await this.pullBookFiles(files);
1368
+ }
1369
+ const deps = this.mergeDeps();
1370
+ for (const remote of files) {
1371
+ pulled += mergeRemoteDevice(remote, deps);
1372
+ }
1373
+ return { pulled, devices: files.length, skipped };
1374
+ }
1375
+ /**
1376
+ * 完整同步:先写本机快照(push),再合并其他设备(pull)
1377
+ */
1378
+ async sync(options = {}) {
1379
+ mkdirSync2(this.devicesDir, { recursive: true });
1380
+ this.writeOwnFile();
1381
+ if (options.withBooks) {
1382
+ this.pushBookFiles();
1383
+ }
1384
+ return this.pull(options);
1385
+ }
1386
+ };
1387
+ function createFolderSyncService() {
1388
+ const dir = getSyncDir();
1389
+ if (!dir) return null;
1390
+ return new SyncFolderService(dir);
1391
+ }
1392
+ async function syncOnOpen(timeoutMs = 2e3) {
1393
+ const service = createFolderSyncService();
1394
+ if (!service) return;
1395
+ await Promise.race([
1396
+ service.pull().catch(() => {
1397
+ }),
1398
+ new Promise((resolve5) => setTimeout(resolve5, timeoutMs))
1399
+ ]);
1400
+ }
1401
+
1402
+ // src/ui/pages/ResumePage.tsx
813
1403
  import { jsx, jsxs } from "react/jsx-runtime";
814
1404
  function ResumePage({ onNavigate }) {
815
1405
  const { exit } = useApp();
816
1406
  const [checking, setChecking] = useState(true);
817
1407
  useEffect(() => {
818
- const progressService = new ProgressService();
819
- const lastProgress = progressService.getLastOpenedBook();
820
- if (!lastProgress) {
821
- setChecking(false);
822
- return;
823
- }
824
- const bookModel = new BookModel();
825
- const book = bookModel.findById(lastProgress.book_id);
826
- if (!book) {
827
- setChecking(false);
828
- return;
829
- }
830
- onNavigate("reader", book.id, lastProgress.byte_offset);
1408
+ void syncOnOpen().finally(() => {
1409
+ const progressService = new ProgressService();
1410
+ const lastProgress = progressService.getLastOpenedBook();
1411
+ if (!lastProgress) {
1412
+ setChecking(false);
1413
+ return;
1414
+ }
1415
+ const bookModel = new BookModel();
1416
+ const book = bookModel.findById(lastProgress.book_id);
1417
+ if (!book) {
1418
+ setChecking(false);
1419
+ return;
1420
+ }
1421
+ onNavigate("reader", book.id, lastProgress.byte_offset);
1422
+ });
831
1423
  }, [onNavigate]);
832
1424
  if (checking) {
833
1425
  return /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u{1F4D6} \u68C0\u67E5\u9605\u8BFB\u8BB0\u5F55..." }) });
@@ -887,9 +1479,11 @@ function LibraryPage({ onNavigate }) {
887
1479
  if (key.return) {
888
1480
  const selected = books[selectedIndex];
889
1481
  if (selected) {
890
- const progressService = new ProgressService();
891
- const progress = progressService.getProgress(selected.id);
892
- onNavigate("reader", selected.id, progress?.byte_offset ?? 0);
1482
+ void syncOnOpen().finally(() => {
1483
+ const progressService = new ProgressService();
1484
+ const progress = progressService.getProgress(selected.id);
1485
+ onNavigate("reader", selected.id, progress?.byte_offset ?? 0);
1486
+ });
893
1487
  }
894
1488
  }
895
1489
  }, { isActive: isRawModeSupported });
@@ -934,6 +1528,7 @@ function LibraryPage({ onNavigate }) {
934
1528
 
935
1529
  // src/ui/pages/ReaderPage.tsx
936
1530
  import { useState as useState5, useEffect as useEffect4, useRef } from "react";
1531
+ import { randomUUID as randomUUID2 } from "crypto";
937
1532
  import { Box as Box6, Text as Text6, useApp as useApp3, useStdout } from "ink";
938
1533
 
939
1534
  // src/ui/components/TextRenderer.tsx
@@ -1148,6 +1743,12 @@ function useReader(pages, initialByteOffset) {
1148
1743
  currentPage: targetPage
1149
1744
  }));
1150
1745
  }, [pages]);
1746
+ const goToFirst = useCallback(() => {
1747
+ setState((prev) => ({ ...prev, currentPage: 0 }));
1748
+ }, []);
1749
+ const goToLast = useCallback(() => {
1750
+ setState((prev) => ({ ...prev, currentPage: Math.max(0, prev.totalPages - 1) }));
1751
+ }, []);
1151
1752
  const getCurrentPage = useCallback(() => {
1152
1753
  return pages[state.currentPage];
1153
1754
  }, [state.currentPage, pages]);
@@ -1166,6 +1767,8 @@ function useReader(pages, initialByteOffset) {
1166
1767
  prevPage,
1167
1768
  goToPage,
1168
1769
  goToOffset,
1770
+ goToFirst,
1771
+ goToLast,
1169
1772
  getCurrentPage,
1170
1773
  getCurrentOffset,
1171
1774
  getPercent,
@@ -1183,9 +1786,15 @@ function useKeyboard(handlers, isActive = true) {
1183
1786
  if (input === " " || input === "j" || key.downArrow || input === "f") {
1184
1787
  handlers.onNext?.();
1185
1788
  }
1186
- if (input === "k" || key.upArrow || input === "b") {
1789
+ if (input === "k" || key.upArrow) {
1187
1790
  handlers.onPrev?.();
1188
1791
  }
1792
+ if (input === "g") {
1793
+ handlers.onGoToStart?.();
1794
+ }
1795
+ if (input === "G") {
1796
+ handlers.onGoToEnd?.();
1797
+ }
1189
1798
  if (input === "q") {
1190
1799
  handlers.onQuit?.();
1191
1800
  }
@@ -1339,56 +1948,6 @@ var RecentService = class {
1339
1948
  }
1340
1949
  };
1341
1950
 
1342
- // src/db/models/Bookmark.ts
1343
- var BookmarkModel = class {
1344
- /**
1345
- * 插入书签
1346
- */
1347
- insert(bookmark) {
1348
- const db2 = getDb();
1349
- db2.prepare(`
1350
- INSERT INTO bookmarks (book_id, title, byte_offset, created_at)
1351
- VALUES (?, ?, ?, ?)
1352
- `).run(bookmark.book_id, bookmark.title, bookmark.byte_offset, bookmark.created_at);
1353
- }
1354
- /**
1355
- * 获取指定书籍的所有书签
1356
- */
1357
- findByBookId(bookId) {
1358
- const db2 = getDb();
1359
- return db2.prepare("SELECT * FROM bookmarks WHERE book_id = ? ORDER BY created_at DESC").all(bookId);
1360
- }
1361
- /**
1362
- * 获取指定书签
1363
- */
1364
- findById(id) {
1365
- const db2 = getDb();
1366
- return db2.prepare("SELECT * FROM bookmarks WHERE id = ?").get(id);
1367
- }
1368
- /**
1369
- * 获取书籍书签总数
1370
- */
1371
- getCount(bookId) {
1372
- const db2 = getDb();
1373
- const result = db2.prepare("SELECT COUNT(*) as count FROM bookmarks WHERE book_id = ?").get(bookId);
1374
- return result.count;
1375
- }
1376
- /**
1377
- * 删除书签
1378
- */
1379
- delete(id) {
1380
- const db2 = getDb();
1381
- db2.prepare("DELETE FROM bookmarks WHERE id = ?").run(id);
1382
- }
1383
- /**
1384
- * 移除整本书的书签 (配合彻底清理书籍使用)
1385
- */
1386
- deleteByBookId(bookId) {
1387
- const db2 = getDb();
1388
- db2.prepare("DELETE FROM bookmarks WHERE book_id = ?").run(bookId);
1389
- }
1390
- };
1391
-
1392
1951
  // src/services/BookmarkService.ts
1393
1952
  var BookmarkService = class {
1394
1953
  bookmarkModel;
@@ -1400,11 +1959,14 @@ var BookmarkService = class {
1400
1959
  * @param title 该书签展现给用户的文案(一句话大纲)
1401
1960
  */
1402
1961
  addBookmark(bookId, title, byteOffset) {
1962
+ const now = Date.now();
1403
1963
  this.bookmarkModel.insert({
1404
1964
  book_id: bookId,
1405
1965
  title,
1406
1966
  byte_offset: byteOffset,
1407
- created_at: Date.now()
1967
+ created_at: now,
1968
+ updated_at: now,
1969
+ deleted: 0
1408
1970
  });
1409
1971
  }
1410
1972
  /**
@@ -1422,24 +1984,82 @@ var BookmarkService = class {
1422
1984
  };
1423
1985
 
1424
1986
  // src/utils/bossKey.ts
1987
+ var isBossKeyEnabled = false;
1425
1988
  function triggerBossKey() {
1426
- process.stdout.write("\x1B[3J\x1B[2J\x1B[1;1H");
1427
- console.clear();
1428
- const fakeLog = `
1429
- file:///Users/yindawei/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916
1989
+ isBossKeyEnabled = true;
1990
+ }
1991
+ function isBossKeyActive() {
1992
+ return isBossKeyEnabled;
1993
+ }
1994
+ var fakeLogs = {
1995
+ nodejs: `
1996
+ file:///home/user/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916
1430
1997
  throw new Error(\`[vite] Failed to resolve module import "./App.vue". Check if the file exists.\`);
1431
1998
  ^
1432
1999
 
1433
2000
  Error: [vite] Failed to resolve module import "./App.vue". Check if the file exists.
1434
- at Object.run (file:///Users/yindawei/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916:13)
1435
- at async file:///Users/yindawei/project/node_modules/vite/dist/node/cli.js:722:7
1436
- at async startVite (file:///Users/yindawei/project/node_modules/vite/dist/node/cli.js:700:5)
1437
- at async Object.handler (file:///Users/yindawei/project/node_modules/vite/dist/node/cli.js:650:1)
2001
+ at Object.run (file:///home/user/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916:13)
2002
+ at async file:///home/user/project/node_modules/vite/dist/node/cli.js:722:7
2003
+ at async startVite (file:///home/user/project/node_modules/vite/dist/node/cli.js:700:5)
2004
+ at async Object.handler (file:///home/user/project/node_modules/vite/dist/node/cli.js:650:1)
1438
2005
 
1439
2006
  Node.js v20.11.0
1440
- `;
1441
- console.log(fakeLog);
1442
- process.exit(0);
2007
+ `,
2008
+ python: `
2009
+ Traceback (most recent call last):
2010
+ File "/home/user/project/main.py", line 42, in <module>
2011
+ result = process_data(df)
2012
+ File "/home/user/project/utils/pipeline.py", line 118, in process_data
2013
+ return df.groupby("user_id").apply(transform)
2014
+ File "/home/user/project/utils/pipeline.py", line 97, in transform
2015
+ raise ValueError(f"Missing required column: '{col}'")
2016
+ ValueError: Missing required column: 'timestamp'
2017
+
2018
+ During handling of the above exception, another exception occurred:
2019
+
2020
+ Traceback (most recent call last):
2021
+ File "/home/user/project/main.py", line 47, in <module>
2022
+ raise RuntimeError("Pipeline failed. Check logs for details.")
2023
+ RuntimeError: Pipeline failed. Check logs for details.
2024
+ `,
2025
+ java: `
2026
+ Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
2027
+ at com.example.app.StringUtils.process(StringUtils.java:34)
2028
+ at com.example.app.DataProcessor.run(DataProcessor.java:112)
2029
+ at com.example.app.Main.main(Main.java:21)
2030
+
2031
+ BUILD FAILURE
2032
+ [ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.11.0:compile
2033
+ [ERROR] -> [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
2034
+ `,
2035
+ c: `
2036
+ make[2]: *** [CMakeFiles/app.dir/src/main.c.o] Error 1
2037
+ make[1]: *** [CMakeFiles/app.dir/all] Error 2
2038
+ make: *** [all] Error 2
2039
+
2040
+ /home/user/project/src/main.c:87:5: error: use of undeclared identifier 'ctx'
2041
+ ctx->buffer = malloc(sizeof(Buffer));
2042
+ ^
2043
+ /home/user/project/src/main.c:102:12: warning: implicit declaration of function 'init_buffer' [-Wimplicit-function-declaration]
2044
+ result = init_buffer(ctx, DEFAULT_SIZE);
2045
+ ^
2046
+ 2 errors, 1 warning generated.
2047
+ `,
2048
+ go: `
2049
+ # command-line-arguments
2050
+ ./main.go:58:13: undefined: parseConfig
2051
+ ./main.go:74:9: cannot use result (variable of type *Response) as type Handler
2052
+ ./main.go:91:2: declared and not used: errCh
2053
+
2054
+ 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
2055
+ exit status 1
2056
+ `
2057
+ };
2058
+ function performBossKeyAction() {
2059
+ process.stdout.write("\x1B[3J\x1B[2J\x1B[1;1H");
2060
+ const lang = getConfig().bossKeyLang ?? "nodejs";
2061
+ const fakeLog = fakeLogs[lang];
2062
+ process.stdout.write(fakeLog + "\n");
1443
2063
  }
1444
2064
 
1445
2065
  // src/utils/time.ts
@@ -1456,6 +2076,20 @@ function formatReadingTime(minutes) {
1456
2076
  return mins > 0 ? `${hours} \u5C0F\u65F6 ${mins} \u5206\u949F` : `${hours} \u5C0F\u65F6`;
1457
2077
  }
1458
2078
 
2079
+ // src/utils/pendingSync.ts
2080
+ var pendingSync = null;
2081
+ function registerPendingSync(promise) {
2082
+ pendingSync = promise;
2083
+ }
2084
+ async function drainPendingSync(timeoutMs = 2e3) {
2085
+ if (!pendingSync) return;
2086
+ await Promise.race([
2087
+ pendingSync.catch(() => {
2088
+ }),
2089
+ new Promise((resolve5) => setTimeout(resolve5, timeoutMs))
2090
+ ]);
2091
+ }
2092
+
1459
2093
  // src/ui/pages/ReaderPage.tsx
1460
2094
  import { Fragment, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1461
2095
  function ReaderContent({
@@ -1465,12 +2099,14 @@ function ReaderContent({
1465
2099
  initialByteOffset,
1466
2100
  termHeight,
1467
2101
  contentHeight,
1468
- lineSpacing
2102
+ lineSpacing,
2103
+ totalChars
1469
2104
  }) {
1470
2105
  const { exit } = useApp3();
1471
2106
  const [chapterTitle, setChapterTitle] = useState5();
1472
2107
  const [currentChapter, setCurrentChapter] = useState5();
1473
2108
  const [showChapterNav, setShowChapterNav] = useState5(false);
2109
+ const [showHelp, setShowHelp] = useState5(false);
1474
2110
  const [allChapters, setAllChapters] = useState5([]);
1475
2111
  const [allBookmarks, setAllBookmarks] = useState5([]);
1476
2112
  const [toastMessage, setToastMessage] = useState5(null);
@@ -1515,6 +2151,34 @@ function ReaderContent({
1515
2151
  setToastMessage(t("tui.reader.bookmark_add", markTitle));
1516
2152
  setTimeout(() => setToastMessage(null), 2e3);
1517
2153
  };
2154
+ const readerRef = useRef(reader);
2155
+ readerRef.current = reader;
2156
+ useEffect4(() => {
2157
+ const startedAt = Date.now();
2158
+ const startOffset = readerRef.current.getCurrentOffset();
2159
+ return () => {
2160
+ const endedAt = Date.now();
2161
+ const endOffset = readerRef.current.getCurrentOffset();
2162
+ if (endedAt - startedAt >= 5e3) {
2163
+ new ReadingSessionModel().insert({
2164
+ id: randomUUID2(),
2165
+ book_id: bookId,
2166
+ started_at: startedAt,
2167
+ ended_at: endedAt,
2168
+ bytes_read: Math.max(0, endOffset - startOffset)
2169
+ });
2170
+ }
2171
+ registerPendingSync(
2172
+ (async () => {
2173
+ const syncService = createFolderSyncService();
2174
+ if (syncService) {
2175
+ await syncService.sync();
2176
+ }
2177
+ })().catch(() => {
2178
+ })
2179
+ );
2180
+ };
2181
+ }, [bookId, book]);
1518
2182
  useEffect4(() => {
1519
2183
  return () => {
1520
2184
  saveReadingProgress();
@@ -1529,20 +2193,29 @@ function ReaderContent({
1529
2193
  onBossKey: () => {
1530
2194
  saveReadingProgress();
1531
2195
  triggerBossKey();
2196
+ exit();
1532
2197
  },
1533
- onBookmarkAdd: handleAddBookmark
2198
+ onBookmarkAdd: handleAddBookmark,
2199
+ onGoToStart: () => reader.goToFirst(),
2200
+ onGoToEnd: () => reader.goToLast(),
2201
+ onHelp: () => setShowHelp((v) => !v)
1534
2202
  },
1535
- !showChapterNav
1536
- // 如果浮层显示,则停止普通的阅读快捷键
2203
+ !showChapterNav && !showHelp
2204
+ // 浮层显示时停止普通的阅读快捷键
1537
2205
  );
1538
2206
  const currentPage = reader.getCurrentPage();
1539
2207
  const currentLines = currentPage?.lines ?? [];
1540
2208
  const calculatedContentHeight = Math.max(1, termHeight - 2);
1541
- const totalChars = (book.file_size ?? 0) / 3;
1542
2209
  const remainingChars = Math.max(0, totalChars * (1 - reader.getPercent()));
1543
2210
  const remainingMinutes = estimateReadingTime(remainingChars, true);
1544
2211
  const remainingTimeStr = formatReadingTime(remainingMinutes);
1545
- return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", height: termHeight, children: !showChapterNav ? /* @__PURE__ */ jsxs5(Fragment, { children: [
2212
+ return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", height: termHeight, children: showHelp ? /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", padding: 1, children: [
2213
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("tui.help.title") }),
2214
+ /* @__PURE__ */ jsx6(Text6, { children: t("tui.help.next") }),
2215
+ /* @__PURE__ */ jsx6(Text6, { children: t("tui.help.prev") }),
2216
+ /* @__PURE__ */ jsx6(Text6, { children: t("tui.help.nav") }),
2217
+ /* @__PURE__ */ jsx6(Text6, { children: t("tui.help.boss") })
2218
+ ] }) : !showChapterNav ? /* @__PURE__ */ jsxs5(Fragment, { children: [
1546
2219
  /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", flexGrow: 1, paddingX: 1, children: /* @__PURE__ */ jsx6(TextRenderer, { lines: currentLines, height: calculatedContentHeight, lineSpacing }) }),
1547
2220
  /* @__PURE__ */ jsx6(
1548
2221
  StatusBar,
@@ -1576,6 +2249,7 @@ function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
1576
2249
  const { stdout } = useStdout();
1577
2250
  const [book, setBook] = useState5(null);
1578
2251
  const [pages, setPages] = useState5(null);
2252
+ const [totalChars, setTotalChars] = useState5(0);
1579
2253
  const [error, setError] = useState5(null);
1580
2254
  const termWidth = stdout?.columns ?? 80;
1581
2255
  const termHeight = stdout?.rows ?? 24;
@@ -1597,6 +2271,7 @@ function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
1597
2271
  const stepSize = appConfig.readingMode === "scroll" ? Math.max(1, Math.floor(contentHeight / 2)) : contentHeight;
1598
2272
  const paginatedPages = paginate(parsed.content, termWidth - 2, contentHeight, stepSize);
1599
2273
  setPages(paginatedPages);
2274
+ setTotalChars(parsed.content.length);
1600
2275
  logger.debug(`\u52A0\u8F7D\u5B8C\u6210: ${bookRecord.title}, ${paginatedPages.length} \u9875, \u6A21\u5F0F: ${appConfig.readingMode}`);
1601
2276
  }).catch((err) => {
1602
2277
  setError(`\u5185\u5BB9\u89E3\u6790\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`);
@@ -1632,7 +2307,8 @@ function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
1632
2307
  initialByteOffset,
1633
2308
  termHeight,
1634
2309
  contentHeight,
1635
- lineSpacing
2310
+ lineSpacing,
2311
+ totalChars
1636
2312
  }
1637
2313
  );
1638
2314
  }
@@ -1673,8 +2349,14 @@ function renderApp(options = {}) {
1673
2349
  initialByteOffset
1674
2350
  })
1675
2351
  );
1676
- waitUntilExit().catch(() => {
2352
+ waitUntilExit().then(async () => {
2353
+ if (isBossKeyActive()) {
2354
+ performBossKeyAction();
2355
+ }
2356
+ await drainPendingSync(2e3);
1677
2357
  process.exit(0);
2358
+ }).catch(() => {
2359
+ process.exit(1);
1678
2360
  });
1679
2361
  }
1680
2362
 
@@ -1684,6 +2366,7 @@ var resumeCommand = {
1684
2366
  describe: t("cli.resume.desc"),
1685
2367
  handler: async () => {
1686
2368
  try {
2369
+ await syncOnOpen();
1687
2370
  const progressService = new ProgressService();
1688
2371
  const lastProgress = progressService.getLastOpenedBook();
1689
2372
  if (!lastProgress) {
@@ -1728,6 +2411,7 @@ var openCommand = {
1728
2411
  console.log(`${t("cli.open.not_found")} ${argv.target}`);
1729
2412
  process.exit(1);
1730
2413
  }
2414
+ await syncOnOpen();
1731
2415
  const progressService = new ProgressService();
1732
2416
  const progress = progressService.getProgress(book.id);
1733
2417
  const byteOffset = progress?.byte_offset ?? 0;
@@ -1840,7 +2524,7 @@ var updateCommand = {
1840
2524
  handler: async () => {
1841
2525
  try {
1842
2526
  console.log(t("cli.update.checking"));
1843
- const localVersion = true ? "0.3.1" : "0.2.2";
2527
+ const localVersion = true ? "1.0.0" : "0.2.2";
1844
2528
  const npmOutput = execSync("npm view readshell version", { encoding: "utf-8" });
1845
2529
  const latestVersion = npmOutput.trim();
1846
2530
  if (!latestVersion) {
@@ -1862,14 +2546,327 @@ var updateCommand = {
1862
2546
  }
1863
2547
  };
1864
2548
 
2549
+ // src/cli/commands/config.ts
2550
+ var BOSS_KEY_LANGS = ["nodejs", "python", "java", "c", "go"];
2551
+ var configCommand = {
2552
+ command: "config <key> <value>",
2553
+ describe: t("cli.config.desc"),
2554
+ builder: (yargs2) => {
2555
+ return yargs2.positional("key", {
2556
+ describe: "\u914D\u7F6E\u9879\u540D\u79F0 (language|line-spacing|reading-mode|boss-key-lang)",
2557
+ type: "string",
2558
+ choices: ["language", "line-spacing", "reading-mode", "boss-key-lang"],
2559
+ demandOption: true
2560
+ }).positional("value", {
2561
+ describe: "\u914D\u7F6E\u9879\u5185\u5BB9",
2562
+ type: "string",
2563
+ demandOption: true
2564
+ });
2565
+ },
2566
+ handler: (argv) => {
2567
+ const { key, value } = argv;
2568
+ try {
2569
+ if (key === "language") {
2570
+ if (value === "zh" || value === "en") {
2571
+ setConfig("language", value);
2572
+ console.log(t("cli.lang.success", value));
2573
+ } else {
2574
+ console.log(t("cli.lang.unsupported", value));
2575
+ }
2576
+ } else if (key === "line-spacing") {
2577
+ const spacing = parseInt(value, 10);
2578
+ if (!isNaN(spacing) && spacing >= 0 && spacing <= 2) {
2579
+ setConfig("lineSpacing", spacing);
2580
+ console.log(t("cli.config.line_spacing.success", spacing));
2581
+ } else {
2582
+ console.log(t("cli.config.line_spacing.desc"));
2583
+ }
2584
+ } else if (key === "reading-mode") {
2585
+ if (value === "page" || value === "scroll") {
2586
+ setConfig("readingMode", value);
2587
+ console.log(`\u2713 Reading mode set to: ${value}`);
2588
+ } else {
2589
+ console.log(t("cli.config.reading_mode.desc"));
2590
+ }
2591
+ } else if (key === "boss-key-lang") {
2592
+ if (BOSS_KEY_LANGS.includes(value)) {
2593
+ setConfig("bossKeyLang", value);
2594
+ console.log(`\u2713 Boss key language set to: ${value}`);
2595
+ } else {
2596
+ console.log(`\u2717 Unsupported language. Choose from: ${BOSS_KEY_LANGS.join(", ")}`);
2597
+ }
2598
+ }
2599
+ } catch (err) {
2600
+ console.error("\u2717 Failed to update config:", err);
2601
+ process.exit(1);
2602
+ }
2603
+ }
2604
+ };
2605
+
2606
+ // src/cli/commands/sync.ts
2607
+ import { existsSync as existsSync5, statSync as statSync3 } from "fs";
2608
+ import { resolve as resolve3 } from "path";
2609
+ var syncCommand = {
2610
+ command: "sync",
2611
+ describe: t("cli.sync.desc"),
2612
+ builder: (yargs2) => yargs2.option("dir", {
2613
+ describe: t("cli.sync.dir"),
2614
+ type: "string"
2615
+ }).option("with-books", {
2616
+ describe: t("cli.sync.with_books"),
2617
+ type: "boolean",
2618
+ default: false
2619
+ }).option("off", {
2620
+ describe: t("cli.sync.off"),
2621
+ type: "boolean",
2622
+ default: false
2623
+ }),
2624
+ handler: async (argv) => {
2625
+ const args = argv;
2626
+ if (args.off) {
2627
+ clearSyncDir();
2628
+ console.log(t("cli.sync.disabled"));
2629
+ return;
2630
+ }
2631
+ if (args.dir) {
2632
+ const dir = resolve3(args.dir);
2633
+ if (!existsSync5(dir) || !statSync3(dir).isDirectory()) {
2634
+ console.log(t("cli.sync.dir_invalid", dir));
2635
+ process.exit(1);
2636
+ }
2637
+ setSyncDir(dir);
2638
+ console.log(t("cli.sync.dir_set", dir));
2639
+ }
2640
+ const syncDir = getSyncDir();
2641
+ if (!syncDir) {
2642
+ console.log(t("cli.sync.no_dir"));
2643
+ process.exit(1);
2644
+ }
2645
+ console.log(t("cli.sync.running"));
2646
+ try {
2647
+ const result = await new SyncFolderService(syncDir).sync({ withBooks: args["with-books"] });
2648
+ console.log(t("cli.sync.success", String(result.pulled), String(result.devices)));
2649
+ } catch (err) {
2650
+ const msg = err instanceof Error ? err.message : String(err);
2651
+ console.log(t("cli.sync.failed", msg));
2652
+ process.exit(1);
2653
+ }
2654
+ }
2655
+ };
2656
+
2657
+ // src/cli/commands/bookmarks.ts
2658
+ import { writeFileSync as writeFileSync2 } from "fs";
2659
+ import { resolve as resolve4 } from "path";
2660
+ function bookmarkPercent(book, b) {
2661
+ if (!book.file_size || book.file_size <= 0) return null;
2662
+ return Math.min(1, Math.max(0, b.byte_offset / book.file_size));
2663
+ }
2664
+ function collectGroups() {
2665
+ const bookModel = new BookModel();
2666
+ const bookmarkModel = new BookmarkModel();
2667
+ const groups = [];
2668
+ for (const book of bookModel.findAll()) {
2669
+ const bookmarks = bookmarkModel.findByBookId(book.id);
2670
+ if (bookmarks.length === 0) continue;
2671
+ groups.push({
2672
+ title: book.title,
2673
+ author: book.author,
2674
+ format: book.format,
2675
+ bookmarks: bookmarks.map((b) => ({
2676
+ label: b.title,
2677
+ byteOffset: b.byte_offset,
2678
+ percent: bookmarkPercent(book, b),
2679
+ createdAt: new Date(b.created_at).toISOString()
2680
+ }))
2681
+ });
2682
+ }
2683
+ return groups;
2684
+ }
2685
+ function toMarkdown(groups) {
2686
+ const lines = ["# ReadShell Bookmarks", ""];
2687
+ for (const g of groups) {
2688
+ lines.push(`## ${g.title}${g.author ? ` \u2014 ${g.author}` : ""}`, "");
2689
+ for (const b of g.bookmarks) {
2690
+ const pct = b.percent !== null ? ` (${(b.percent * 100).toFixed(1)}%)` : "";
2691
+ lines.push(`- ${b.label} \u2014 offset ${b.byteOffset}${pct} \u2014 ${b.createdAt}`);
2692
+ }
2693
+ lines.push("");
2694
+ }
2695
+ return lines.join("\n");
2696
+ }
2697
+ var bookmarksCommand = {
2698
+ command: "bookmarks",
2699
+ describe: t("cli.bookmarks.desc"),
2700
+ builder: (yargs2) => yargs2.command({
2701
+ command: "export",
2702
+ describe: t("cli.bookmarks.export.desc"),
2703
+ builder: (yy) => yy.option("format", {
2704
+ describe: t("cli.bookmarks.export.format"),
2705
+ type: "string",
2706
+ choices: ["json", "md"],
2707
+ default: "md"
2708
+ }).option("out", {
2709
+ describe: t("cli.bookmarks.export.out"),
2710
+ type: "string"
2711
+ }),
2712
+ handler: async (argv) => {
2713
+ const args = argv;
2714
+ try {
2715
+ const groups = collectGroups();
2716
+ const total = groups.reduce((n, g) => n + g.bookmarks.length, 0);
2717
+ if (total === 0) {
2718
+ console.log(t("cli.bookmarks.export.empty"));
2719
+ return;
2720
+ }
2721
+ const output = args.format === "json" ? JSON.stringify(groups, null, 2) : toMarkdown(groups);
2722
+ if (args.out) {
2723
+ const outPath = resolve4(args.out);
2724
+ writeFileSync2(outPath, output + "\n", "utf-8");
2725
+ console.log(t("cli.bookmarks.export.success", String(total), outPath));
2726
+ } else {
2727
+ console.log(output);
2728
+ }
2729
+ } catch (err) {
2730
+ const msg = err instanceof Error ? err.message : String(err);
2731
+ console.log(t("cli.bookmarks.export.fail", msg));
2732
+ process.exit(1);
2733
+ }
2734
+ }
2735
+ }).demandCommand(1),
2736
+ handler: () => {
2737
+ }
2738
+ };
2739
+
2740
+ // src/services/StatsService.ts
2741
+ function dayKey(d) {
2742
+ const m = String(d.getMonth() + 1).padStart(2, "0");
2743
+ const day = String(d.getDate()).padStart(2, "0");
2744
+ return `${d.getFullYear()}-${m}-${day}`;
2745
+ }
2746
+ function dayDiff(a, b) {
2747
+ const [ay, am, ad] = a.split("-").map(Number);
2748
+ const [by, bm, bd] = b.split("-").map(Number);
2749
+ return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 864e5);
2750
+ }
2751
+ function aggregateSessions(sessions, now = /* @__PURE__ */ new Date()) {
2752
+ const minutesByDay = /* @__PURE__ */ new Map();
2753
+ const books = /* @__PURE__ */ new Set();
2754
+ let totalMinutes = 0;
2755
+ for (const s of sessions) {
2756
+ const minutes = Math.max(0, (s.ended_at - s.started_at) / 6e4);
2757
+ if (minutes <= 0) continue;
2758
+ const key = dayKey(new Date(s.started_at));
2759
+ minutesByDay.set(key, (minutesByDay.get(key) ?? 0) + minutes);
2760
+ books.add(s.book_id);
2761
+ totalMinutes += minutes;
2762
+ }
2763
+ const days = [...minutesByDay.keys()].sort();
2764
+ let longestStreak = 0;
2765
+ let run = 0;
2766
+ for (let i = 0; i < days.length; i++) {
2767
+ run = i > 0 && dayDiff(days[i - 1], days[i]) === 1 ? run + 1 : 1;
2768
+ longestStreak = Math.max(longestStreak, run);
2769
+ }
2770
+ let currentStreak = 0;
2771
+ if (days.length > 0) {
2772
+ const today = dayKey(now);
2773
+ const yesterday = dayKey(new Date(now.getTime() - 864e5));
2774
+ const last = days[days.length - 1];
2775
+ if (last === today || last === yesterday) {
2776
+ currentStreak = 1;
2777
+ for (let i = days.length - 2; i >= 0; i--) {
2778
+ if (dayDiff(days[i], days[i + 1]) === 1) currentStreak++;
2779
+ else break;
2780
+ }
2781
+ }
2782
+ }
2783
+ return {
2784
+ minutesByDay,
2785
+ readingDays: days.length,
2786
+ totalMinutes: Math.round(totalMinutes),
2787
+ booksRead: books.size,
2788
+ currentStreak,
2789
+ longestStreak
2790
+ };
2791
+ }
2792
+ var LEVEL_CHARS = ["\xB7", "\u2591", "\u2592", "\u2593", "\u2588"];
2793
+ var LEVEL_COLORS = ["38;5;238", "38;5;22", "38;5;28", "38;5;34", "38;5;40"];
2794
+ function levelOf(minutes) {
2795
+ if (minutes <= 0) return 0;
2796
+ if (minutes < 15) return 1;
2797
+ if (minutes < 30) return 2;
2798
+ if (minutes < 60) return 3;
2799
+ return 4;
2800
+ }
2801
+ function renderHeatmap(minutesByDay, opts) {
2802
+ const now = opts.now ?? /* @__PURE__ */ new Date();
2803
+ const labelWidth = Math.max(...opts.dayLabels.map((l) => getStringWidth(l))) + 1;
2804
+ const weeks = Math.max(4, Math.min(53, Math.floor((opts.width - labelWidth) / 2)));
2805
+ const dow = (now.getDay() + 6) % 7;
2806
+ const endMonday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - dow);
2807
+ const start = new Date(endMonday.getTime() - (weeks - 1) * 7 * 864e5);
2808
+ const todayKey = dayKey(now);
2809
+ const cell = (date) => {
2810
+ const key = dayKey(date);
2811
+ if (key > todayKey) return " ";
2812
+ const level = levelOf(minutesByDay.get(key) ?? 0);
2813
+ if (!opts.color) return `${LEVEL_CHARS[level]} `;
2814
+ return `\x1B[${LEVEL_COLORS[level]}m\u2588\x1B[0m `;
2815
+ };
2816
+ const lines = [];
2817
+ for (let row = 0; row < 7; row++) {
2818
+ const label = row === 0 ? opts.dayLabels[0] : row === 2 ? opts.dayLabels[1] : row === 4 ? opts.dayLabels[2] : "";
2819
+ let line = label + " ".repeat(Math.max(0, labelWidth - getStringWidth(label)));
2820
+ for (let w = 0; w < weeks; w++) {
2821
+ line += cell(new Date(start.getTime() + (w * 7 + row) * 864e5));
2822
+ }
2823
+ lines.push(line.trimEnd());
2824
+ }
2825
+ return lines.join("\n");
2826
+ }
2827
+ function renderLegend(less, more, color) {
2828
+ const cells = LEVEL_CHARS.map(
2829
+ (ch, i) => color ? `\x1B[${LEVEL_COLORS[i]}m\u2588\x1B[0m` : ch
2830
+ ).join(" ");
2831
+ return `${less} ${cells} ${more}`;
2832
+ }
2833
+
2834
+ // src/cli/commands/stats.ts
2835
+ var statsCommand = {
2836
+ command: "stats",
2837
+ describe: t("cli.stats.desc"),
2838
+ handler: () => {
2839
+ const sessions = new ReadingSessionModel().findAll();
2840
+ const stats = aggregateSessions(sessions);
2841
+ const lang = getConfig().language;
2842
+ const width = process.stdout.columns ?? 80;
2843
+ const color = Boolean(process.stdout.isTTY);
2844
+ const dayLabels = lang === "en" ? ["Mon", "Wed", "Fri"] : ["\u4E00", "\u4E09", "\u4E94"];
2845
+ console.log(t("cli.stats.title"));
2846
+ console.log("");
2847
+ console.log(renderHeatmap(stats.minutesByDay, { width, dayLabels, color }));
2848
+ console.log("");
2849
+ console.log(renderLegend(t("cli.stats.less"), t("cli.stats.more"), color));
2850
+ console.log("");
2851
+ console.log(t(
2852
+ "cli.stats.summary",
2853
+ String(stats.readingDays),
2854
+ String(stats.totalMinutes),
2855
+ String(stats.booksRead),
2856
+ String(stats.currentStreak),
2857
+ String(stats.longestStreak)
2858
+ ));
2859
+ }
2860
+ };
2861
+
1865
2862
  // src/cli/parser.ts
1866
2863
  function createParser() {
1867
- const version = true ? "0.3.1" : "dev";
1868
- 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).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 \u2014 \u7EC8\u7AEF\u5185\u4F4E\u6253\u65AD\u8F7B\u9605\u8BFB\u5DE5\u5177");
2864
+ const version = true ? "1.0.0" : "dev";
2865
+ 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(configCommand).command(syncCommand).command(bookmarksCommand).command(statsCommand).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 \u2014 \u7EC8\u7AEF\u5185\u4F4E\u6253\u65AD\u8F7B\u9605\u8BFB\u5DE5\u5177");
1869
2866
  }
1870
2867
 
1871
2868
  // src/db/migrate.ts
1872
- var SCHEMA_VERSION = 2;
2869
+ var SCHEMA_VERSION2 = 4;
1873
2870
  function initDatabase() {
1874
2871
  const db2 = getDb();
1875
2872
  db2.exec(`
@@ -1879,8 +2876,8 @@ function initDatabase() {
1879
2876
  `);
1880
2877
  const row = db2.prepare("SELECT version FROM schema_version LIMIT 1").get();
1881
2878
  const currentVersion = row?.version ?? 0;
1882
- if (currentVersion < SCHEMA_VERSION) {
1883
- logger.debug(`\u6570\u636E\u5E93\u8FC1\u79FB: v${currentVersion} \u2192 v${SCHEMA_VERSION}`);
2879
+ if (currentVersion < SCHEMA_VERSION2) {
2880
+ logger.debug(`\u6570\u636E\u5E93\u8FC1\u79FB: v${currentVersion} \u2192 v${SCHEMA_VERSION2}`);
1884
2881
  migrate(db2, currentVersion);
1885
2882
  }
1886
2883
  }
@@ -1941,10 +2938,32 @@ function migrate(db2, fromVersion) {
1941
2938
  );
1942
2939
 
1943
2940
  CREATE INDEX IF NOT EXISTS idx_bookmarks_book ON bookmarks(book_id);
2941
+ `,
2942
+ 3: `
2943
+ -- \u9605\u8BFB\u4F1A\u8BDD\uFF08\u7528\u4E8E\u7EDF\u8BA1\u4E0E\u8DE8\u8BBE\u5907\u540C\u6B65\uFF09
2944
+ CREATE TABLE IF NOT EXISTS reading_sessions (
2945
+ id TEXT PRIMARY KEY,
2946
+ book_id TEXT NOT NULL REFERENCES books(id),
2947
+ started_at INTEGER NOT NULL,
2948
+ ended_at INTEGER NOT NULL,
2949
+ bytes_read INTEGER NOT NULL DEFAULT 0,
2950
+ synced INTEGER NOT NULL DEFAULT 0
2951
+ );
2952
+
2953
+ CREATE INDEX IF NOT EXISTS idx_sessions_book ON reading_sessions(book_id);
2954
+ `,
2955
+ 4: `
2956
+ -- \u4E66\u7B7E\u5893\u7891\uFF1A\u6587\u4EF6\u5939\u540C\u6B65\u9700\u8981\u4F20\u64AD\u5220\u9664\u4E8B\u4EF6
2957
+ ALTER TABLE bookmarks ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0;
2958
+ ALTER TABLE bookmarks ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
2959
+ UPDATE bookmarks SET updated_at = created_at;
2960
+
2961
+ -- \u6587\u4EF6\u5939\u540C\u6B65\u6309 id \u53BB\u91CD\u5408\u5E76\u4F1A\u8BDD\uFF0C\u4E0D\u518D\u9700\u8981 synced \u6807\u8BB0
2962
+ ALTER TABLE reading_sessions DROP COLUMN synced;
1944
2963
  `
1945
2964
  };
1946
2965
  db2.transaction(() => {
1947
- for (let v = fromVersion + 1; v <= SCHEMA_VERSION; v++) {
2966
+ for (let v = fromVersion + 1; v <= SCHEMA_VERSION2; v++) {
1948
2967
  const sql = migrations[v];
1949
2968
  if (sql) {
1950
2969
  db2.exec(sql);
@@ -1952,7 +2971,7 @@ function migrate(db2, fromVersion) {
1952
2971
  }
1953
2972
  }
1954
2973
  db2.prepare("DELETE FROM schema_version").run();
1955
- db2.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION);
2974
+ db2.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION2);
1956
2975
  })();
1957
2976
  }
1958
2977