readshell 0.3.2 → 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/LICENSE +17 -13
- package/README.md +74 -22
- package/dist/index.js +1222 -215
- package/dist/index.js.map +1 -1
- package/package.json +10 -5
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
|
|
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
|
|
21
|
-
let
|
|
22
|
-
if (
|
|
23
|
-
|
|
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
|
-
|
|
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(
|
|
30
|
-
mkdirSync(
|
|
34
|
+
if (!existsSync(configDir2)) {
|
|
35
|
+
mkdirSync(configDir2, { recursive: true });
|
|
31
36
|
}
|
|
32
|
-
return
|
|
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((
|
|
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", () =>
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
|
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:
|
|
1967
|
+
created_at: now,
|
|
1968
|
+
updated_at: now,
|
|
1969
|
+
deleted: 0
|
|
1408
1970
|
});
|
|
1409
1971
|
}
|
|
1410
1972
|
/**
|
|
@@ -1429,23 +1991,75 @@ function triggerBossKey() {
|
|
|
1429
1991
|
function isBossKeyActive() {
|
|
1430
1992
|
return isBossKeyEnabled;
|
|
1431
1993
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
file:///Users/yindawei/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916
|
|
1994
|
+
var fakeLogs = {
|
|
1995
|
+
nodejs: `
|
|
1996
|
+
file:///home/user/project/node_modules/vite/dist/node/chunks/dep-BbV93i69.js:43916
|
|
1436
1997
|
throw new Error(\`[vite] Failed to resolve module import "./App.vue". Check if the file exists.\`);
|
|
1437
1998
|
^
|
|
1438
1999
|
|
|
1439
2000
|
Error: [vite] Failed to resolve module import "./App.vue". Check if the file exists.
|
|
1440
|
-
at Object.run (file:///
|
|
1441
|
-
at async file:///
|
|
1442
|
-
at async startVite (file:///
|
|
1443
|
-
at async Object.handler (file:///
|
|
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)
|
|
1444
2005
|
|
|
1445
2006
|
Node.js v20.11.0
|
|
1446
|
-
|
|
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];
|
|
1447
2062
|
process.stdout.write(fakeLog + "\n");
|
|
1448
|
-
process.exit(0);
|
|
1449
2063
|
}
|
|
1450
2064
|
|
|
1451
2065
|
// src/utils/time.ts
|
|
@@ -1462,6 +2076,20 @@ function formatReadingTime(minutes) {
|
|
|
1462
2076
|
return mins > 0 ? `${hours} \u5C0F\u65F6 ${mins} \u5206\u949F` : `${hours} \u5C0F\u65F6`;
|
|
1463
2077
|
}
|
|
1464
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
|
+
|
|
1465
2093
|
// src/ui/pages/ReaderPage.tsx
|
|
1466
2094
|
import { Fragment, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
1467
2095
|
function ReaderContent({
|
|
@@ -1471,12 +2099,14 @@ function ReaderContent({
|
|
|
1471
2099
|
initialByteOffset,
|
|
1472
2100
|
termHeight,
|
|
1473
2101
|
contentHeight,
|
|
1474
|
-
lineSpacing
|
|
2102
|
+
lineSpacing,
|
|
2103
|
+
totalChars
|
|
1475
2104
|
}) {
|
|
1476
2105
|
const { exit } = useApp3();
|
|
1477
2106
|
const [chapterTitle, setChapterTitle] = useState5();
|
|
1478
2107
|
const [currentChapter, setCurrentChapter] = useState5();
|
|
1479
2108
|
const [showChapterNav, setShowChapterNav] = useState5(false);
|
|
2109
|
+
const [showHelp, setShowHelp] = useState5(false);
|
|
1480
2110
|
const [allChapters, setAllChapters] = useState5([]);
|
|
1481
2111
|
const [allBookmarks, setAllBookmarks] = useState5([]);
|
|
1482
2112
|
const [toastMessage, setToastMessage] = useState5(null);
|
|
@@ -1521,6 +2151,34 @@ function ReaderContent({
|
|
|
1521
2151
|
setToastMessage(t("tui.reader.bookmark_add", markTitle));
|
|
1522
2152
|
setTimeout(() => setToastMessage(null), 2e3);
|
|
1523
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]);
|
|
1524
2182
|
useEffect4(() => {
|
|
1525
2183
|
return () => {
|
|
1526
2184
|
saveReadingProgress();
|
|
@@ -1537,19 +2195,27 @@ function ReaderContent({
|
|
|
1537
2195
|
triggerBossKey();
|
|
1538
2196
|
exit();
|
|
1539
2197
|
},
|
|
1540
|
-
onBookmarkAdd: handleAddBookmark
|
|
2198
|
+
onBookmarkAdd: handleAddBookmark,
|
|
2199
|
+
onGoToStart: () => reader.goToFirst(),
|
|
2200
|
+
onGoToEnd: () => reader.goToLast(),
|
|
2201
|
+
onHelp: () => setShowHelp((v) => !v)
|
|
1541
2202
|
},
|
|
1542
|
-
!showChapterNav
|
|
1543
|
-
//
|
|
2203
|
+
!showChapterNav && !showHelp
|
|
2204
|
+
// 浮层显示时停止普通的阅读快捷键
|
|
1544
2205
|
);
|
|
1545
2206
|
const currentPage = reader.getCurrentPage();
|
|
1546
2207
|
const currentLines = currentPage?.lines ?? [];
|
|
1547
2208
|
const calculatedContentHeight = Math.max(1, termHeight - 2);
|
|
1548
|
-
const totalChars = (book.file_size ?? 0) / 3;
|
|
1549
2209
|
const remainingChars = Math.max(0, totalChars * (1 - reader.getPercent()));
|
|
1550
2210
|
const remainingMinutes = estimateReadingTime(remainingChars, true);
|
|
1551
2211
|
const remainingTimeStr = formatReadingTime(remainingMinutes);
|
|
1552
|
-
return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", height: termHeight, 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: [
|
|
1553
2219
|
/* @__PURE__ */ jsx6(Box6, { flexDirection: "column", flexGrow: 1, paddingX: 1, children: /* @__PURE__ */ jsx6(TextRenderer, { lines: currentLines, height: calculatedContentHeight, lineSpacing }) }),
|
|
1554
2220
|
/* @__PURE__ */ jsx6(
|
|
1555
2221
|
StatusBar,
|
|
@@ -1583,6 +2249,7 @@ function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
|
|
|
1583
2249
|
const { stdout } = useStdout();
|
|
1584
2250
|
const [book, setBook] = useState5(null);
|
|
1585
2251
|
const [pages, setPages] = useState5(null);
|
|
2252
|
+
const [totalChars, setTotalChars] = useState5(0);
|
|
1586
2253
|
const [error, setError] = useState5(null);
|
|
1587
2254
|
const termWidth = stdout?.columns ?? 80;
|
|
1588
2255
|
const termHeight = stdout?.rows ?? 24;
|
|
@@ -1604,6 +2271,7 @@ function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
|
|
|
1604
2271
|
const stepSize = appConfig.readingMode === "scroll" ? Math.max(1, Math.floor(contentHeight / 2)) : contentHeight;
|
|
1605
2272
|
const paginatedPages = paginate(parsed.content, termWidth - 2, contentHeight, stepSize);
|
|
1606
2273
|
setPages(paginatedPages);
|
|
2274
|
+
setTotalChars(parsed.content.length);
|
|
1607
2275
|
logger.debug(`\u52A0\u8F7D\u5B8C\u6210: ${bookRecord.title}, ${paginatedPages.length} \u9875, \u6A21\u5F0F: ${appConfig.readingMode}`);
|
|
1608
2276
|
}).catch((err) => {
|
|
1609
2277
|
setError(`\u5185\u5BB9\u89E3\u6790\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1639,7 +2307,8 @@ function ReaderPage({ bookId, initialByteOffset, onNavigate: _onNavigate }) {
|
|
|
1639
2307
|
initialByteOffset,
|
|
1640
2308
|
termHeight,
|
|
1641
2309
|
contentHeight,
|
|
1642
|
-
lineSpacing
|
|
2310
|
+
lineSpacing,
|
|
2311
|
+
totalChars
|
|
1643
2312
|
}
|
|
1644
2313
|
);
|
|
1645
2314
|
}
|
|
@@ -1680,10 +2349,11 @@ function renderApp(options = {}) {
|
|
|
1680
2349
|
initialByteOffset
|
|
1681
2350
|
})
|
|
1682
2351
|
);
|
|
1683
|
-
waitUntilExit().then(() => {
|
|
2352
|
+
waitUntilExit().then(async () => {
|
|
1684
2353
|
if (isBossKeyActive()) {
|
|
1685
2354
|
performBossKeyAction();
|
|
1686
2355
|
}
|
|
2356
|
+
await drainPendingSync(2e3);
|
|
1687
2357
|
process.exit(0);
|
|
1688
2358
|
}).catch(() => {
|
|
1689
2359
|
process.exit(1);
|
|
@@ -1696,6 +2366,7 @@ var resumeCommand = {
|
|
|
1696
2366
|
describe: t("cli.resume.desc"),
|
|
1697
2367
|
handler: async () => {
|
|
1698
2368
|
try {
|
|
2369
|
+
await syncOnOpen();
|
|
1699
2370
|
const progressService = new ProgressService();
|
|
1700
2371
|
const lastProgress = progressService.getLastOpenedBook();
|
|
1701
2372
|
if (!lastProgress) {
|
|
@@ -1740,6 +2411,7 @@ var openCommand = {
|
|
|
1740
2411
|
console.log(`${t("cli.open.not_found")} ${argv.target}`);
|
|
1741
2412
|
process.exit(1);
|
|
1742
2413
|
}
|
|
2414
|
+
await syncOnOpen();
|
|
1743
2415
|
const progressService = new ProgressService();
|
|
1744
2416
|
const progress = progressService.getProgress(book.id);
|
|
1745
2417
|
const byteOffset = progress?.byte_offset ?? 0;
|
|
@@ -1852,7 +2524,7 @@ var updateCommand = {
|
|
|
1852
2524
|
handler: async () => {
|
|
1853
2525
|
try {
|
|
1854
2526
|
console.log(t("cli.update.checking"));
|
|
1855
|
-
const localVersion = true ? "0.
|
|
2527
|
+
const localVersion = true ? "1.0.0" : "0.2.2";
|
|
1856
2528
|
const npmOutput = execSync("npm view readshell version", { encoding: "utf-8" });
|
|
1857
2529
|
const latestVersion = npmOutput.trim();
|
|
1858
2530
|
if (!latestVersion) {
|
|
@@ -1874,14 +2546,327 @@ var updateCommand = {
|
|
|
1874
2546
|
}
|
|
1875
2547
|
};
|
|
1876
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
|
+
|
|
1877
2862
|
// src/cli/parser.ts
|
|
1878
2863
|
function createParser() {
|
|
1879
|
-
const version = true ? "0.
|
|
1880
|
-
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");
|
|
1881
2866
|
}
|
|
1882
2867
|
|
|
1883
2868
|
// src/db/migrate.ts
|
|
1884
|
-
var
|
|
2869
|
+
var SCHEMA_VERSION2 = 4;
|
|
1885
2870
|
function initDatabase() {
|
|
1886
2871
|
const db2 = getDb();
|
|
1887
2872
|
db2.exec(`
|
|
@@ -1891,8 +2876,8 @@ function initDatabase() {
|
|
|
1891
2876
|
`);
|
|
1892
2877
|
const row = db2.prepare("SELECT version FROM schema_version LIMIT 1").get();
|
|
1893
2878
|
const currentVersion = row?.version ?? 0;
|
|
1894
|
-
if (currentVersion <
|
|
1895
|
-
logger.debug(`\u6570\u636E\u5E93\u8FC1\u79FB: v${currentVersion} \u2192 v${
|
|
2879
|
+
if (currentVersion < SCHEMA_VERSION2) {
|
|
2880
|
+
logger.debug(`\u6570\u636E\u5E93\u8FC1\u79FB: v${currentVersion} \u2192 v${SCHEMA_VERSION2}`);
|
|
1896
2881
|
migrate(db2, currentVersion);
|
|
1897
2882
|
}
|
|
1898
2883
|
}
|
|
@@ -1953,10 +2938,32 @@ function migrate(db2, fromVersion) {
|
|
|
1953
2938
|
);
|
|
1954
2939
|
|
|
1955
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;
|
|
1956
2963
|
`
|
|
1957
2964
|
};
|
|
1958
2965
|
db2.transaction(() => {
|
|
1959
|
-
for (let v = fromVersion + 1; v <=
|
|
2966
|
+
for (let v = fromVersion + 1; v <= SCHEMA_VERSION2; v++) {
|
|
1960
2967
|
const sql = migrations[v];
|
|
1961
2968
|
if (sql) {
|
|
1962
2969
|
db2.exec(sql);
|
|
@@ -1964,7 +2971,7 @@ function migrate(db2, fromVersion) {
|
|
|
1964
2971
|
}
|
|
1965
2972
|
}
|
|
1966
2973
|
db2.prepare("DELETE FROM schema_version").run();
|
|
1967
|
-
db2.prepare("INSERT INTO schema_version (version) VALUES (?)").run(
|
|
2974
|
+
db2.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION2);
|
|
1968
2975
|
})();
|
|
1969
2976
|
}
|
|
1970
2977
|
|