reader-shell 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/README.md +51 -0
- package/cli.js +1 -0
- package/package.json +18 -0
- package/src/config.js +18 -0
- package/src/epubReader.js +35 -0
- package/src/progressStore.js +42 -0
- package/src/ui.js +157 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# 终端电子书阅读器
|
|
2
|
+
|
|
3
|
+
一个基于 Node.js 的终端电子书阅读器,支持导入 epub 文件、记住阅读进度、快捷键翻页和字体颜色切换。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
- 支持导入 epub 电子书
|
|
7
|
+
- 记住每本书的阅读进度(包括章节和章节内滚动位置)
|
|
8
|
+
- 支持快捷键翻页、章节内滚动
|
|
9
|
+
- 支持老板键一键隐藏内容
|
|
10
|
+
- 纯终端操作,跨平台
|
|
11
|
+
|
|
12
|
+
## 安装依赖
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 使用方法
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node src/ui.js <你的电子书.epub>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
例如:
|
|
25
|
+
```bash
|
|
26
|
+
node src/ui.js ./books/三体.epub
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 快捷键说明
|
|
30
|
+
- `→` 或 `e`:下一章
|
|
31
|
+
- `←` 或 `w`:上一章
|
|
32
|
+
- `↑` 或 `s`:章节内向上滚动
|
|
33
|
+
- `↓` 或 `d`:章节内向下滚动
|
|
34
|
+
- `a`:老板键(快速隐藏/恢复内容)
|
|
35
|
+
- `q` 或 `Ctrl+C`:退出阅读器
|
|
36
|
+
|
|
37
|
+
## 进度存储
|
|
38
|
+
- 阅读进度会自动保存在 `progress.json` 文件中,每本书独立记录。
|
|
39
|
+
- 记忆内容包括:当前章节、章节内滚动位置。
|
|
40
|
+
|
|
41
|
+
## 常见问题
|
|
42
|
+
- **中文乱码**:已内置 HTML 实体解码和编码处理,绝大多数 epub 可正常显示中文。
|
|
43
|
+
- **终端不显示中文**:请确保终端编码为 `UTF-8`,并使用支持中文的字体。
|
|
44
|
+
|
|
45
|
+
## 依赖说明
|
|
46
|
+
- [epub](https://www.npmjs.com/package/epub) 电子书解析
|
|
47
|
+
- [blessed](https://www.npmjs.com/package/blessed) 终端 UI
|
|
48
|
+
- [chalk](https://www.npmjs.com/package/chalk) 终端字体颜色
|
|
49
|
+
|
|
50
|
+
## 许可协议
|
|
51
|
+
MIT
|
package/cli.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './src/ui.js'
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reader-shell",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "cli.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [],
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "ISC",
|
|
12
|
+
"description": "",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"blessed": "^0.1.81",
|
|
15
|
+
"chalk": "^5.4.1",
|
|
16
|
+
"epub": "^1.3.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const config = {
|
|
2
|
+
fontColor: "white",
|
|
3
|
+
title: "node",
|
|
4
|
+
bossContent: ` @ multi (webpack)-dev-server/client?http://localhost:8081/sockjs-node (webpack)/hot/dev-server.js ./src/main.js
|
|
5
|
+
|
|
6
|
+
warning in ./src/pages/reader/index.vue?vue&type=template&id=6430da5c&scoped=true&
|
|
7
|
+
|
|
8
|
+
Module Warning (from ./node_modules/@dcloudio/vue-cli-plugin-uni/packages/vue-loader/lib/loaders/templateLoader.js):
|
|
9
|
+
(Emitted value instead of an instance of Error) <v-uni-view v-for="label in item.label">: component lists rendered with v-for should have explicit keys. See https://vuejs.org/guide/list.html#key for more info.
|
|
10
|
+
|
|
11
|
+
@ ./src/pages/reader/index.vue?vue&type=template&id=6430da5c&scoped=true& 1:0-701 1:0-701
|
|
12
|
+
@ ./src/pages/reader/index.vue
|
|
13
|
+
@ ./src/pages.json
|
|
14
|
+
@ ./src/main.js
|
|
15
|
+
@ multi (webpack)-dev-server/client?http://localhost:8081/sockjs-node (webpack)/hot/dev-server.js ./src/main.js
|
|
16
|
+
|
|
17
|
+
`
|
|
18
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import EPub from 'epub';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 解析epub文件,返回章节文本数组
|
|
5
|
+
* @param {string} epubPath epub文件路径
|
|
6
|
+
* @returns {Promise<string[]>} 章节文本数组
|
|
7
|
+
*/
|
|
8
|
+
function parseEpub(epubPath) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const epub = new EPub(epubPath);
|
|
11
|
+
epub.on('error', reject);
|
|
12
|
+
epub.on('end', function() {
|
|
13
|
+
const chapterIds = epub.flow.map(ch => ch.id);
|
|
14
|
+
const chapters = [];
|
|
15
|
+
let loaded = 0;
|
|
16
|
+
chapterIds.forEach((id, idx) => {
|
|
17
|
+
epub.getChapterRaw(id, (err, data) => {
|
|
18
|
+
if (err) {
|
|
19
|
+
chapters[idx] = '';
|
|
20
|
+
} else {
|
|
21
|
+
let text = data.toString('utf-8');
|
|
22
|
+
chapters[idx] = text.replace(/<[^>]+>/g, '').replace(/\r\n/g, '') + '\n\n 本章完';
|
|
23
|
+
}
|
|
24
|
+
loaded++;
|
|
25
|
+
if (loaded === chapterIds.length) {
|
|
26
|
+
resolve(chapters);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
epub.parse();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { parseEpub };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
const STORE_PATH = path.join(__dirname, '../progress.json');
|
|
9
|
+
|
|
10
|
+
function loadProgress() {
|
|
11
|
+
if (!fs.existsSync(STORE_PATH)) return {};
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf-8'));
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function saveProgress(progress) {
|
|
20
|
+
fs.writeFileSync(STORE_PATH, JSON.stringify(progress, null, 2), 'utf-8');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getBookProgress(bookPath) {
|
|
24
|
+
const progress = loadProgress();
|
|
25
|
+
const val = progress[bookPath];
|
|
26
|
+
if (typeof val === 'number') {
|
|
27
|
+
// 兼容旧数据
|
|
28
|
+
return { chapter: val, scroll: 0 };
|
|
29
|
+
}
|
|
30
|
+
return val || { chapter: 0, scroll: 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setBookProgress(bookPath, obj) {
|
|
34
|
+
const progress = loadProgress();
|
|
35
|
+
progress[bookPath] = obj;
|
|
36
|
+
saveProgress(progress);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
getBookProgress,
|
|
41
|
+
setBookProgress
|
|
42
|
+
};
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import blessed from "blessed";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { parseEpub } from "./epubReader.js";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { getBookProgress, setBookProgress } from "./progressStore.js";
|
|
6
|
+
import { config } from "./config.js";
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
|
|
9
|
+
// 默认字体颜色
|
|
10
|
+
let fontColor = config.fontColor;
|
|
11
|
+
const colorFns = {
|
|
12
|
+
white: chalk.white,
|
|
13
|
+
green: chalk.green,
|
|
14
|
+
cyan: chalk.cyan,
|
|
15
|
+
magenta: chalk.magenta,
|
|
16
|
+
yellow: chalk.yellow,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function startReader(epubPath) {
|
|
20
|
+
const chapters = await parseEpub(epubPath);
|
|
21
|
+
const progress = getBookProgress(epubPath);
|
|
22
|
+
let currentChapter = (progress && progress.chapter) || 0;
|
|
23
|
+
let currentScroll = (progress && progress.scroll) || 0;
|
|
24
|
+
|
|
25
|
+
// 创建blessed屏幕
|
|
26
|
+
const screen = blessed.screen({
|
|
27
|
+
smartCSR: true,
|
|
28
|
+
fullUnicode: true,
|
|
29
|
+
title: config.title,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// 掩人耳目,假装在开发
|
|
33
|
+
const boss = blessed.box({
|
|
34
|
+
top: 0,
|
|
35
|
+
left: 0,
|
|
36
|
+
width: "100%",
|
|
37
|
+
height: "80%",
|
|
38
|
+
border: "none",
|
|
39
|
+
content: config.bossContent
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// 章节内容框
|
|
43
|
+
const box = blessed.box({
|
|
44
|
+
top: "70%",
|
|
45
|
+
left: 0,
|
|
46
|
+
width: "300",
|
|
47
|
+
height: "30%",
|
|
48
|
+
tags: true,
|
|
49
|
+
scrollable: true,
|
|
50
|
+
alwaysScroll: true,
|
|
51
|
+
keys: true,
|
|
52
|
+
focused: true,
|
|
53
|
+
mouse: true,
|
|
54
|
+
vi: true,
|
|
55
|
+
border: "none",
|
|
56
|
+
style: {
|
|
57
|
+
fg: fontColor,
|
|
58
|
+
border: { fg: "cyan" },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 状态栏
|
|
63
|
+
const status = blessed.box({
|
|
64
|
+
bottom: 0,
|
|
65
|
+
left: 0,
|
|
66
|
+
width: "100%",
|
|
67
|
+
height: "10%",
|
|
68
|
+
tags: true,
|
|
69
|
+
style: { fg: fontColor },
|
|
70
|
+
});
|
|
71
|
+
screen.append(boss);
|
|
72
|
+
screen.append(box);
|
|
73
|
+
screen.append(status);
|
|
74
|
+
|
|
75
|
+
function render() {
|
|
76
|
+
const colorFn = colorFns[fontColor] || chalk.white;
|
|
77
|
+
box.setContent(colorFn(chapters[currentChapter] || ""));
|
|
78
|
+
status.setContent(
|
|
79
|
+
`progress: ${currentScroll}/${box.getScrollHeight()} | chapter: ${currentChapter + 1}/${
|
|
80
|
+
chapters.length
|
|
81
|
+
} | ←/w:prev →/e:next ↑/s:up ↓/d:down a:boss q:exit`
|
|
82
|
+
);
|
|
83
|
+
// 恢复滚动位置
|
|
84
|
+
box.setScroll(currentScroll);
|
|
85
|
+
screen.render();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveProgress() {
|
|
89
|
+
setBookProgress(epubPath, { chapter: currentChapter, scroll: currentScroll });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function changeChapter(chapter) {
|
|
93
|
+
currentChapter += chapter;
|
|
94
|
+
currentScroll = 0;
|
|
95
|
+
saveProgress();
|
|
96
|
+
render();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 快捷键
|
|
100
|
+
screen.key(["right", "e"], () => {
|
|
101
|
+
if (currentChapter < chapters.length - 1) {
|
|
102
|
+
changeChapter(1)
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
screen.key(["left", "w"], () => {
|
|
106
|
+
if (currentChapter > 0) {
|
|
107
|
+
changeChapter(-1)
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
screen.key(["up", "s"], () => {
|
|
111
|
+
currentScroll--;
|
|
112
|
+
if (currentScroll < 0) {
|
|
113
|
+
changeChapter(-1)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
render();
|
|
117
|
+
});
|
|
118
|
+
screen.key(["down", "d"], () => {
|
|
119
|
+
currentScroll++;
|
|
120
|
+
if (currentScroll > box.getScrollHeight()) {
|
|
121
|
+
changeChapter(1)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
render();
|
|
125
|
+
});
|
|
126
|
+
screen.key(["a"], () => {
|
|
127
|
+
// 老板键
|
|
128
|
+
if (box.height === 0) {
|
|
129
|
+
boss.height = '80%'
|
|
130
|
+
box.height = '30%'
|
|
131
|
+
status.height = '10%'
|
|
132
|
+
} else {
|
|
133
|
+
boss.height = '100%'
|
|
134
|
+
box.height = 0
|
|
135
|
+
status.height = 0
|
|
136
|
+
}
|
|
137
|
+
render()
|
|
138
|
+
});
|
|
139
|
+
screen.key(["q", "C-c"], () => {
|
|
140
|
+
saveProgress();
|
|
141
|
+
process.exit(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
box.on('scroll', () => {
|
|
145
|
+
saveProgress();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
render();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 允许命令行直接运行
|
|
152
|
+
const epubPath = process.argv[2];
|
|
153
|
+
if (!epubPath) {
|
|
154
|
+
console.error("用法: node src/ui.js <epub文件路径>");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
startReader(path.resolve(epubPath));
|