access-calibre 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 +19 -0
- package/README.md +164 -0
- package/index.js +4 -0
- package/mcp-server.js +366 -0
- package/package.json +36 -0
- package/src/client.js +375 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# access-calibre
|
|
2
|
+
|
|
3
|
+
> [!NOTE]
|
|
4
|
+
> This package has been entirely vibe-coded by Junie in JetBrains IntelliJ and not human checked.
|
|
5
|
+
|
|
6
|
+
A JavaScript library to connect to a local Calibre Content Server and grab portions of ebooks.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install access-calibre
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
You can use it as a library in your project:
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
const CalibreClient = require('./src/client');
|
|
20
|
+
|
|
21
|
+
const client = new CalibreClient('http://localhost:8080', 'username', 'password');
|
|
22
|
+
|
|
23
|
+
async function example() {
|
|
24
|
+
// List libraries
|
|
25
|
+
const libraries = await client.getLibraries();
|
|
26
|
+
const libraryId = Object.keys(libraries)[0];
|
|
27
|
+
|
|
28
|
+
// List books
|
|
29
|
+
const books = await client.getBooks(libraryId);
|
|
30
|
+
|
|
31
|
+
// Get EPUB contents
|
|
32
|
+
const bookId = books.book_ids[0];
|
|
33
|
+
const files = await client.getEpubContents(libraryId, bookId);
|
|
34
|
+
console.log('Files in EPUB:', files);
|
|
35
|
+
|
|
36
|
+
// Get a specific file (e.g., a chapter) from the EPUB
|
|
37
|
+
const html = await client.getEpubFile(libraryId, bookId, 'index_split_001.html');
|
|
38
|
+
console.log(html);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
### `new CalibreClient(baseUrl, username, password)`
|
|
45
|
+
Initialize the client.
|
|
46
|
+
|
|
47
|
+
### `client.getLibraries()`
|
|
48
|
+
Returns an object containing information about available libraries.
|
|
49
|
+
|
|
50
|
+
### `client.getBooks(libraryId, limit, offset)`
|
|
51
|
+
Returns a list of book IDs and their metadata for the specified library.
|
|
52
|
+
|
|
53
|
+
### `client.getBookMetadata(libraryId, bookId)`
|
|
54
|
+
Returns detailed metadata for a specific book.
|
|
55
|
+
|
|
56
|
+
### `client.downloadBook(libraryId, bookId, format)`
|
|
57
|
+
Downloads the book in the specified format (e.g., 'EPUB', 'PDF') and returns a Buffer.
|
|
58
|
+
|
|
59
|
+
### `client.getEpubContents(libraryId, bookId)`
|
|
60
|
+
Returns a list of file paths inside the EPUB file.
|
|
61
|
+
|
|
62
|
+
### `client.getTOC(libraryId, bookId)`
|
|
63
|
+
Returns an array of `{ title, path }` objects representing the book's Table of Contents.
|
|
64
|
+
|
|
65
|
+
### `client.getChapters(libraryId, bookId)`
|
|
66
|
+
Returns an array of `{ title, path }` objects representing the book's chapters in their linear reading order (as defined in the EPUB spine).
|
|
67
|
+
|
|
68
|
+
### `client.getEpubFile(libraryId, bookId, filePath, responseType)`
|
|
69
|
+
Extracts a specific file from the EPUB and returns its content as a string (default) or Buffer (if `responseType` is `'buffer'`).
|
|
70
|
+
|
|
71
|
+
## Scripts
|
|
72
|
+
|
|
73
|
+
### `get-chapter.js`
|
|
74
|
+
Extracts the XML/HTML content of a chapter to the console.
|
|
75
|
+
```bash
|
|
76
|
+
node get-chapter.js <book_id_or_title> <chapter_index_or_title>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `render-chapter.js`
|
|
80
|
+
Renders a specific page from a chapter to a PNG image using Playwright.
|
|
81
|
+
```bash
|
|
82
|
+
node render-chapter.js <book_id_or_title> <chapter_index_or_title> [output.png] [--page <number>]
|
|
83
|
+
```
|
|
84
|
+
- `--page <number>`: Optional. Specifies which page of the chapter to render (default is 1).
|
|
85
|
+
- If no output filename is provided, it defaults to `chapter_render.png`.
|
|
86
|
+
|
|
87
|
+
Note: You may need to run `npx playwright install chromium` before using this script.
|
|
88
|
+
|
|
89
|
+
## MCP Server
|
|
90
|
+
|
|
91
|
+
This library includes an MCP (Model Context Protocol) server that allows LLMs to interact with your Calibre library.
|
|
92
|
+
|
|
93
|
+
### Tools Provided:
|
|
94
|
+
- `list_libraries`: List available libraries.
|
|
95
|
+
- `search_books`: Search for books by title or author across all libraries.
|
|
96
|
+
- `list_books`: List all books in a library.
|
|
97
|
+
- `list_chapters`: List the chapters of a book in reading order.
|
|
98
|
+
- `get_chapter_content`: Retrieve the HTML content of a specific chapter.
|
|
99
|
+
- `render_chapter_page`: Render a specific page of a chapter as a PNG image (useful for LLMs with vision capabilities). It also returns the total number of pages in the chapter.
|
|
100
|
+
- `get_book_cover`: Retrieve the cover image of a book as a PNG image.
|
|
101
|
+
- `get_epub_file`: Retrieve any file from the EPUB (e.g. CSS, images).
|
|
102
|
+
|
|
103
|
+
### Configuration
|
|
104
|
+
The MCP server can be configured via environment variables:
|
|
105
|
+
- `CALIBRE_URL`: The URL of your Calibre Content Server (default: `http://[::1]:8080/`).
|
|
106
|
+
- `CALIBRE_USERNAME`: Optional username for authentication.
|
|
107
|
+
- `CALIBRE_PASSWORD`: Optional password for authentication.
|
|
108
|
+
|
|
109
|
+
### Running the MCP Server
|
|
110
|
+
|
|
111
|
+
#### Claude Desktop
|
|
112
|
+
|
|
113
|
+
You can run the MCP server directly using `npx` (useful for Claude Desktop or other MCP clients):
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx access-calibre
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### LM Studio
|
|
120
|
+
|
|
121
|
+
To use this MCP server in LM Studio:
|
|
122
|
+
|
|
123
|
+
1. Open **LM Studio**.
|
|
124
|
+
2. Click on the **Tools** icon (puzzle piece) in the left sidebar.
|
|
125
|
+
3. Click **+ Add Tool**.
|
|
126
|
+
4. Choose **MCP Server**.
|
|
127
|
+
5. Give it a name (e.g., `access-calibre`).
|
|
128
|
+
6. For **Command**, enter `npx`.
|
|
129
|
+
7. For **Arguments**, enter `access-calibre`.
|
|
130
|
+
8. Add any required environment variables (like `CALIBRE_URL`) in the **Environment Variables** section.
|
|
131
|
+
9. Click **Add Tool**.
|
|
132
|
+
|
|
133
|
+
Alternatively, you can add it to your `mcp-config.json` (usually found in `~/.lmstudio/mcp-config.json`):
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"mcpServers": {
|
|
138
|
+
"access-calibre": {
|
|
139
|
+
"command": "npx",
|
|
140
|
+
"args": ["access-calibre"],
|
|
141
|
+
"env": {
|
|
142
|
+
"CALIBRE_URL": "http://localhost:8080/"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### Local Installation
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm run start:mcp
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Or directly via Node:
|
|
156
|
+
```bash
|
|
157
|
+
CALIBRE_URL=http://localhost:8080 node mcp-server.js
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Running Tests
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npm test
|
|
164
|
+
```
|
package/index.js
ADDED
package/mcp-server.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
|
|
3
|
+
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
4
|
+
const {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ErrorCode,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
McpError,
|
|
9
|
+
} = require("@modelcontextprotocol/sdk/types.js");
|
|
10
|
+
const CalibreClient = require("./index");
|
|
11
|
+
|
|
12
|
+
const CALIBRE_URL = process.env.CALIBRE_URL || "http://[::1]:8080/";
|
|
13
|
+
const CALIBRE_USERNAME = process.env.CALIBRE_USERNAME || null;
|
|
14
|
+
const CALIBRE_PASSWORD = process.env.CALIBRE_PASSWORD || null;
|
|
15
|
+
|
|
16
|
+
const client = new CalibreClient(CALIBRE_URL, CALIBRE_USERNAME, CALIBRE_PASSWORD);
|
|
17
|
+
|
|
18
|
+
const server = new Server(
|
|
19
|
+
{
|
|
20
|
+
name: "calibre-reader",
|
|
21
|
+
version: "1.0.0",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
capabilities: {
|
|
25
|
+
tools: {},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
31
|
+
return {
|
|
32
|
+
tools: [
|
|
33
|
+
{
|
|
34
|
+
name: "list_libraries",
|
|
35
|
+
description: "List available Calibre libraries",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
// Some MCP clients/LLMs struggle with empty properties
|
|
40
|
+
_config: {
|
|
41
|
+
type: "object",
|
|
42
|
+
description: "Optional configuration (unused)",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: [],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "search_books",
|
|
50
|
+
description: "Search for books across all libraries by title or author fragment",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
query: {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "Title or author fragment to search for",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
required: ["query"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "list_books",
|
|
64
|
+
description: "List books in a specific library",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
libraryId: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "The ID of the library",
|
|
71
|
+
},
|
|
72
|
+
limit: {
|
|
73
|
+
type: "number",
|
|
74
|
+
description: "Maximum number of books to return (default 100)",
|
|
75
|
+
},
|
|
76
|
+
offset: {
|
|
77
|
+
type: "number",
|
|
78
|
+
description: "Offset for pagination (default 0)",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: ["libraryId"],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "list_chapters",
|
|
86
|
+
description: "List chapters (reading order) of a specific book",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
libraryId: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "The ID of the library",
|
|
93
|
+
},
|
|
94
|
+
bookId: {
|
|
95
|
+
type: "number",
|
|
96
|
+
description: "The ID of the book",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
required: ["libraryId", "bookId"],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "get_chapter_content",
|
|
104
|
+
description: "Get the HTML content of a specific chapter",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
libraryId: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "The ID of the library",
|
|
111
|
+
},
|
|
112
|
+
bookId: {
|
|
113
|
+
type: "number",
|
|
114
|
+
description: "The ID of the book",
|
|
115
|
+
},
|
|
116
|
+
path: {
|
|
117
|
+
type: "string",
|
|
118
|
+
description: "The internal file path of the chapter (from list_chapters)",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
required: ["libraryId", "bookId", "path"],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "render_chapter_page",
|
|
126
|
+
description: "Render a specific page of a chapter as a PNG image",
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: "object",
|
|
129
|
+
properties: {
|
|
130
|
+
libraryId: {
|
|
131
|
+
type: "string",
|
|
132
|
+
description: "The ID of the library",
|
|
133
|
+
},
|
|
134
|
+
bookId: {
|
|
135
|
+
type: "number",
|
|
136
|
+
description: "The ID of the book",
|
|
137
|
+
},
|
|
138
|
+
path: {
|
|
139
|
+
type: "string",
|
|
140
|
+
description: "The internal file path of the chapter (from list_chapters)",
|
|
141
|
+
},
|
|
142
|
+
page: {
|
|
143
|
+
type: "number",
|
|
144
|
+
description: "The page number to render (default 1)",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
required: ["libraryId", "bookId", "path"],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "get_book_cover",
|
|
152
|
+
description: "Get the cover image of a book as a PNG",
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
libraryId: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description: "The ID of the library",
|
|
159
|
+
},
|
|
160
|
+
bookId: {
|
|
161
|
+
type: "number",
|
|
162
|
+
description: "The ID of the book",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
required: ["libraryId", "bookId"],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "get_epub_file",
|
|
170
|
+
description: "Retrieve any file from the EPUB (e.g. CSS, images)",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
libraryId: {
|
|
175
|
+
type: "string",
|
|
176
|
+
description: "The ID of the library",
|
|
177
|
+
},
|
|
178
|
+
bookId: {
|
|
179
|
+
type: "number",
|
|
180
|
+
description: "The ID of the book",
|
|
181
|
+
},
|
|
182
|
+
path: {
|
|
183
|
+
type: "string",
|
|
184
|
+
description: "The internal file path in the EPUB",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: ["libraryId", "bookId", "path"],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
195
|
+
const { name, arguments: args } = request.params;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
switch (name) {
|
|
199
|
+
case "list_libraries": {
|
|
200
|
+
const libraries = await client.getLibraries();
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: "text", text: JSON.stringify(libraries, null, 2) }],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case "search_books": {
|
|
207
|
+
if (!args.query) {
|
|
208
|
+
throw new McpError(ErrorCode.InvalidParams, "Query is required");
|
|
209
|
+
}
|
|
210
|
+
const query = args.query.toLowerCase();
|
|
211
|
+
const libraries = await client.getLibraries();
|
|
212
|
+
const results = [];
|
|
213
|
+
|
|
214
|
+
for (const libraryId of Object.keys(libraries)) {
|
|
215
|
+
const books = await client.getBooks(libraryId);
|
|
216
|
+
if (Array.isArray(books)) {
|
|
217
|
+
for (const book of books) {
|
|
218
|
+
const title = (book.title || "").toLowerCase();
|
|
219
|
+
const authors = Array.isArray(book.authors)
|
|
220
|
+
? book.authors.join(", ").toLowerCase()
|
|
221
|
+
: (book.authors || "").toLowerCase();
|
|
222
|
+
|
|
223
|
+
if (title.includes(query) || authors.includes(query)) {
|
|
224
|
+
results.push({
|
|
225
|
+
libraryId,
|
|
226
|
+
libraryName: libraries[libraryId],
|
|
227
|
+
bookId: book.id || book.application_id,
|
|
228
|
+
title: book.title,
|
|
229
|
+
authors: book.authors
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case "list_books": {
|
|
241
|
+
if (!args.libraryId) {
|
|
242
|
+
throw new McpError(ErrorCode.InvalidParams, "libraryId is required");
|
|
243
|
+
}
|
|
244
|
+
const books = await client.getBooks(args.libraryId, args.limit || 100, args.offset || 0);
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: "text", text: JSON.stringify(books, null, 2) }],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case "list_chapters": {
|
|
251
|
+
if (!args.libraryId || !args.bookId) {
|
|
252
|
+
throw new McpError(ErrorCode.InvalidParams, "libraryId and bookId are required");
|
|
253
|
+
}
|
|
254
|
+
const chapters = await client.getChapters(args.libraryId, args.bookId);
|
|
255
|
+
return {
|
|
256
|
+
content: [{ type: "text", text: JSON.stringify(chapters, null, 2) }],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case "get_chapter_content": {
|
|
261
|
+
if (!args.libraryId || !args.bookId || !args.path) {
|
|
262
|
+
throw new McpError(ErrorCode.InvalidParams, "libraryId, bookId and path are required");
|
|
263
|
+
}
|
|
264
|
+
const content = await client.getEpubFile(args.libraryId, args.bookId, args.path);
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text", text: content }],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case "render_chapter_page": {
|
|
271
|
+
if (!args.libraryId || !args.bookId || !args.path) {
|
|
272
|
+
throw new McpError(ErrorCode.InvalidParams, "libraryId, bookId and path are required");
|
|
273
|
+
}
|
|
274
|
+
const { buffer, totalPages } = await client.renderChapterPage(
|
|
275
|
+
args.libraryId,
|
|
276
|
+
args.bookId,
|
|
277
|
+
args.path,
|
|
278
|
+
args.page || 1
|
|
279
|
+
);
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: "text",
|
|
284
|
+
text: `Page ${args.page || 1} of ${totalPages}`,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
type: "image",
|
|
288
|
+
data: buffer.toString("base64"),
|
|
289
|
+
mimeType: "image/png",
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case "get_book_cover": {
|
|
296
|
+
if (!args.libraryId || !args.bookId) {
|
|
297
|
+
throw new McpError(ErrorCode.InvalidParams, "libraryId and bookId are required");
|
|
298
|
+
}
|
|
299
|
+
const buffer = await client.getBookCover(args.libraryId, args.bookId);
|
|
300
|
+
return {
|
|
301
|
+
content: [
|
|
302
|
+
{
|
|
303
|
+
type: "image",
|
|
304
|
+
data: buffer.toString("base64"),
|
|
305
|
+
mimeType: "image/png",
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
type: "text",
|
|
309
|
+
text: `Cover image for book ID ${args.bookId} in library ${args.libraryId}`,
|
|
310
|
+
}
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case "get_epub_file": {
|
|
316
|
+
if (!args.libraryId || !args.bookId || !args.path) {
|
|
317
|
+
throw new McpError(ErrorCode.InvalidParams, "libraryId, bookId and path are required");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(args.path);
|
|
321
|
+
const responseType = isImage ? 'buffer' : 'text';
|
|
322
|
+
|
|
323
|
+
const content = await client.getEpubFile(args.libraryId, args.bookId, args.path, responseType);
|
|
324
|
+
|
|
325
|
+
if (isImage) {
|
|
326
|
+
const mimeType = args.path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
|
|
327
|
+
return {
|
|
328
|
+
content: [
|
|
329
|
+
{
|
|
330
|
+
type: "image",
|
|
331
|
+
data: content.toString("base64"),
|
|
332
|
+
mimeType: mimeType,
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: "text", text: content }],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
default:
|
|
344
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error instanceof McpError) {
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
352
|
+
isError: true,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
async function main() {
|
|
358
|
+
const transport = new StdioServerTransport();
|
|
359
|
+
await server.connect(transport);
|
|
360
|
+
console.error("Calibre Reader MCP server running on stdio");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
main().catch((error) => {
|
|
364
|
+
console.error("Server error:", error);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "access-calibre",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A JavaScript library and MCP server to connect to a local Calibre Content Server and grab portions of ebooks.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"access-calibre": "./mcp-server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"mcp-server.js",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "jest",
|
|
19
|
+
"start:mcp": "node mcp-server.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["mcp", "calibre", "llm", "book", "books"],
|
|
22
|
+
"author": "kybernetikos <me@kybernetikos.com>",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "commonjs",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
27
|
+
"adm-zip": "^0.5.16",
|
|
28
|
+
"axios": "^1.13.4",
|
|
29
|
+
"chromium": "^3.0.3",
|
|
30
|
+
"playwright-core": "^1.58.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"jest": "^30.2.0",
|
|
34
|
+
"nock": "^14.0.10"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const AdmZip = require('adm-zip');
|
|
3
|
+
const { chromium } = require('playwright-core');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
class CalibreClient {
|
|
9
|
+
constructor(baseUrl, username = null, password = null) {
|
|
10
|
+
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
11
|
+
this.auth = (username && password) ? { username, password } : null;
|
|
12
|
+
this.client = axios.create({
|
|
13
|
+
baseURL: this.baseUrl,
|
|
14
|
+
auth: this.auth
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get a list of libraries available on the server.
|
|
20
|
+
*/
|
|
21
|
+
async getLibraries() {
|
|
22
|
+
const response = await this.client.get('/interface-data/init');
|
|
23
|
+
return response.data.library_info || response.data.library_map || {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get books from a specific library.
|
|
28
|
+
* @param {string} libraryId
|
|
29
|
+
* @param {number} limit
|
|
30
|
+
* @param {number} offset
|
|
31
|
+
*/
|
|
32
|
+
async getBooks(libraryId, limit = 100, offset = 0) {
|
|
33
|
+
// Calibre's JSON API often uses /ajax/books/
|
|
34
|
+
const response = await this.client.get(`/ajax/books/${libraryId}`, {
|
|
35
|
+
params: {
|
|
36
|
+
num: limit,
|
|
37
|
+
start: offset
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Some versions of Calibre return an object with book IDs as keys,
|
|
42
|
+
// others return an object with a 'books' array.
|
|
43
|
+
// We try to normalize this to an array of books.
|
|
44
|
+
if (response.data && response.data.books) {
|
|
45
|
+
return response.data.books;
|
|
46
|
+
} else if (response.data && typeof response.data === 'object') {
|
|
47
|
+
const values = Object.values(response.data);
|
|
48
|
+
const books = values.filter(item => typeof item === 'object' && item !== null && (item.id !== undefined || item.application_id !== undefined));
|
|
49
|
+
|
|
50
|
+
if (books.length > 0) {
|
|
51
|
+
return books;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return response.data;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get detailed metadata for a specific book.
|
|
59
|
+
*/
|
|
60
|
+
async getBookMetadata(libraryId, bookId) {
|
|
61
|
+
const response = await this.client.get(`/ajax/book/${bookId}/${libraryId}`);
|
|
62
|
+
return response.data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the formats available for a book.
|
|
67
|
+
*/
|
|
68
|
+
async getBookFormats(libraryId, bookId) {
|
|
69
|
+
const metadata = await this.getBookMetadata(libraryId, bookId);
|
|
70
|
+
return metadata.formats;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Download a specific format of a book.
|
|
75
|
+
* Note: This returns a Buffer.
|
|
76
|
+
*/
|
|
77
|
+
async downloadBook(libraryId, bookId, format) {
|
|
78
|
+
const url = `/get/${format}/${bookId}/${libraryId}`;
|
|
79
|
+
const response = await this.client.get(url, { responseType: 'arraybuffer' });
|
|
80
|
+
return Buffer.from(response.data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the cover image of a book.
|
|
85
|
+
* Returns a Buffer.
|
|
86
|
+
*/
|
|
87
|
+
async getBookCover(libraryId, bookId) {
|
|
88
|
+
const url = `/get/cover/${bookId}/${libraryId}`;
|
|
89
|
+
const response = await this.client.get(url, { responseType: 'arraybuffer' });
|
|
90
|
+
return Buffer.from(response.data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get a list of files inside an EPUB book.
|
|
95
|
+
*/
|
|
96
|
+
async getEpubContents(libraryId, bookId) {
|
|
97
|
+
const buffer = await this.getEpubBuffer(libraryId, bookId);
|
|
98
|
+
const zip = new AdmZip(buffer);
|
|
99
|
+
return zip.getEntries().map(entry => entry.entryName);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract a specific file from an EPUB.
|
|
104
|
+
* Returns the content as a string or Buffer depending on the format.
|
|
105
|
+
*/
|
|
106
|
+
async getEpubFile(libraryId, bookId, filePath, responseType = 'text') {
|
|
107
|
+
const buffer = await this.getEpubBuffer(libraryId, bookId);
|
|
108
|
+
const zip = new AdmZip(buffer);
|
|
109
|
+
const entry = zip.getEntry(filePath);
|
|
110
|
+
if (!entry) throw new Error(`File ${filePath} not found in EPUB`);
|
|
111
|
+
|
|
112
|
+
if (responseType === 'buffer') {
|
|
113
|
+
return entry.getData();
|
|
114
|
+
}
|
|
115
|
+
return zip.readAsText(entry);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get a Buffer of the entire EPUB file.
|
|
119
|
+
*/
|
|
120
|
+
async getEpubBuffer(libraryId, bookId) {
|
|
121
|
+
const formats = await this.getBookFormats(libraryId, bookId);
|
|
122
|
+
const epubFormat = formats.find(f => f.toUpperCase() === 'EPUB');
|
|
123
|
+
if (!epubFormat) throw new Error(`Book ${bookId} does not have an EPUB format`);
|
|
124
|
+
|
|
125
|
+
return await this.downloadBook(libraryId, bookId, epubFormat);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse Manifest
|
|
130
|
+
*/
|
|
131
|
+
_parseManifest(opfXml, opfDir) {
|
|
132
|
+
const manifest = {};
|
|
133
|
+
const itemRegex = /<item\s+[^>]*href\s*=\s*"([^"]+)"[^>]*id\s*=\s*"([^"]+)"|<item\s+[^>]*id\s*=\s*"([^"]+)"[^>]*href\s*=\s*"([^"]+)"/gi;
|
|
134
|
+
let itemMatch;
|
|
135
|
+
while ((itemMatch = itemRegex.exec(opfXml)) !== null) {
|
|
136
|
+
const id = itemMatch[2] || itemMatch[3];
|
|
137
|
+
const href = itemMatch[1] || itemMatch[4];
|
|
138
|
+
// Decode URI components in href (e.g. %20 to space)
|
|
139
|
+
let decodedHref = href;
|
|
140
|
+
try {
|
|
141
|
+
decodedHref = decodeURIComponent(href);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// Ignore decoding errors
|
|
144
|
+
}
|
|
145
|
+
manifest[id] = this._normalizePath(opfDir + decodedHref);
|
|
146
|
+
}
|
|
147
|
+
return manifest;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Normalize path (remove ./ and handle ../)
|
|
152
|
+
*/
|
|
153
|
+
_normalizePath(filePath) {
|
|
154
|
+
// Simple normalization for internal EPUB paths
|
|
155
|
+
const parts = filePath.split('/');
|
|
156
|
+
const stack = [];
|
|
157
|
+
for (const part of parts) {
|
|
158
|
+
if (part === '.' || part === '') continue;
|
|
159
|
+
if (part === '..') {
|
|
160
|
+
if (stack.length > 0) stack.pop();
|
|
161
|
+
} else {
|
|
162
|
+
stack.push(part);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return stack.join('/');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build chapters list
|
|
170
|
+
*/
|
|
171
|
+
async getChapters(libraryId, bookId) {
|
|
172
|
+
const buffer = await this.getEpubBuffer(libraryId, bookId);
|
|
173
|
+
const zip = new AdmZip(buffer);
|
|
174
|
+
|
|
175
|
+
// 1. Find the OPF file
|
|
176
|
+
const containerEntry = zip.getEntry('META-INF/container.xml');
|
|
177
|
+
if (!containerEntry) throw new Error('EPUB missing META-INF/container.xml');
|
|
178
|
+
const containerXml = zip.readAsText(containerEntry);
|
|
179
|
+
const fullPathMatch = containerXml.match(/full-path="([^"]+)"/);
|
|
180
|
+
if (!fullPathMatch) throw new Error('Could not find root file in container.xml');
|
|
181
|
+
const opfPath = fullPathMatch[1];
|
|
182
|
+
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
|
|
183
|
+
|
|
184
|
+
const opfEntry = zip.getEntry(opfPath);
|
|
185
|
+
if (!opfEntry) throw new Error(`Could not find OPF file at ${opfPath}`);
|
|
186
|
+
const opfXml = zip.readAsText(opfEntry);
|
|
187
|
+
|
|
188
|
+
// 2. Parse Manifest
|
|
189
|
+
const manifest = this._parseManifest(opfXml, opfDir);
|
|
190
|
+
|
|
191
|
+
// 3. Parse Spine
|
|
192
|
+
const spine = [];
|
|
193
|
+
const itemrefRegex = /<itemref\s+[^>]*idref\s*=\s*"([^"]+)"/gi;
|
|
194
|
+
let itemrefMatch;
|
|
195
|
+
while ((itemrefMatch = itemrefRegex.exec(opfXml)) !== null) {
|
|
196
|
+
const idref = itemrefMatch[1];
|
|
197
|
+
if (manifest[idref]) {
|
|
198
|
+
spine.push(manifest[idref]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 4. Get TOC for titles
|
|
203
|
+
const toc = await this.getTOC(libraryId, bookId);
|
|
204
|
+
const tocMap = {};
|
|
205
|
+
toc.forEach(item => {
|
|
206
|
+
const normalizedPath = this._normalizePath(item.path);
|
|
207
|
+
if (!tocMap[normalizedPath]) {
|
|
208
|
+
tocMap[normalizedPath] = item.title;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// 5. Build chapters list
|
|
213
|
+
return spine.map(chapterPath => ({
|
|
214
|
+
title: tocMap[chapterPath] || 'No heading',
|
|
215
|
+
path: chapterPath
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Renders a specific page of a chapter to a PNG Buffer.
|
|
221
|
+
*/
|
|
222
|
+
async renderChapterPage(libraryId, bookId, chapterPath, pageNumber = 1, width = 800, height = 1000) {
|
|
223
|
+
const epubBuffer = await this.getEpubBuffer(libraryId, bookId);
|
|
224
|
+
const zip = new AdmZip(epubBuffer);
|
|
225
|
+
|
|
226
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'calibre-render-'));
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// Only extract the requested chapter and its immediate dependencies if possible?
|
|
230
|
+
// Actually, EPUBs often have CSS/images in other directories.
|
|
231
|
+
// Extracting all is safer for rendering, but let's ensure we only extract once if possible.
|
|
232
|
+
zip.extractAllTo(tempDir, true);
|
|
233
|
+
|
|
234
|
+
const htmlPath = path.join(tempDir, chapterPath);
|
|
235
|
+
if (!fs.existsSync(htmlPath)) {
|
|
236
|
+
// Try to find it if path is relative or slightly different
|
|
237
|
+
throw new Error(`Extracted chapter file not found at expected path: ${chapterPath}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const browser = await chromium.launch({
|
|
241
|
+
args: ['--disable-web-security'] // Allow loading local files
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
const page = await browser.newPage();
|
|
245
|
+
await page.setViewportSize({ width, height });
|
|
246
|
+
|
|
247
|
+
// Use a proper file:// URL
|
|
248
|
+
const fileUrl = `file://${path.resolve(htmlPath)}`;
|
|
249
|
+
await page.goto(fileUrl, { waitUntil: 'networkidle' });
|
|
250
|
+
|
|
251
|
+
const totalPages = await page.evaluate((h) => {
|
|
252
|
+
return Math.ceil(document.documentElement.scrollHeight / h) || 1;
|
|
253
|
+
}, height);
|
|
254
|
+
|
|
255
|
+
if (pageNumber > 1) {
|
|
256
|
+
await page.evaluate(({ n, h }) => {
|
|
257
|
+
window.scrollTo(0, (n - 1) * h);
|
|
258
|
+
}, { n: pageNumber, h: height });
|
|
259
|
+
|
|
260
|
+
// Give it a moment to scroll and render
|
|
261
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
|
265
|
+
return {
|
|
266
|
+
buffer: screenshotBuffer,
|
|
267
|
+
totalPages: totalPages
|
|
268
|
+
};
|
|
269
|
+
} finally {
|
|
270
|
+
await browser.close();
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
try {
|
|
274
|
+
if (fs.existsSync(tempDir)) {
|
|
275
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error(`Failed to clean up temp directory ${tempDir}: ${err.message}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get the Table of Contents (TOC) from an EPUB.
|
|
285
|
+
* Returns an array of { title, path } objects.
|
|
286
|
+
*/
|
|
287
|
+
async getTOC(libraryId, bookId) {
|
|
288
|
+
const buffer = await this.getEpubBuffer(libraryId, bookId);
|
|
289
|
+
const zip = new AdmZip(buffer);
|
|
290
|
+
const entries = zip.getEntries();
|
|
291
|
+
|
|
292
|
+
// 1. Find the OPF file to find the TOC file
|
|
293
|
+
const containerEntry = zip.getEntry('META-INF/container.xml');
|
|
294
|
+
if (!containerEntry) throw new Error('EPUB missing META-INF/container.xml');
|
|
295
|
+
const containerXml = zip.readAsText(containerEntry);
|
|
296
|
+
const fullPathMatch = containerXml.match(/full-path="([^"]+)"/);
|
|
297
|
+
if (!fullPathMatch) throw new Error('Could not find root file in container.xml');
|
|
298
|
+
const opfPath = fullPathMatch[1];
|
|
299
|
+
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
|
|
300
|
+
|
|
301
|
+
const opfEntry = zip.getEntry(opfPath);
|
|
302
|
+
if (!opfEntry) throw new Error(`Could not find OPF file at ${opfPath}`);
|
|
303
|
+
const opfXml = zip.readAsText(opfEntry);
|
|
304
|
+
|
|
305
|
+
// Try to find TOC in NCX (EPUB 2) or Nav (EPUB 3)
|
|
306
|
+
// Check for NCX first
|
|
307
|
+
const ncxMatch = opfXml.match(/id="([^"]+)"[^>]+media-type="application\/x-dtbncx\+xml"/i) ||
|
|
308
|
+
opfXml.match(/media-type="application\/x-dtbncx\+xml"[^>]+id="([^"]+)"/i);
|
|
309
|
+
|
|
310
|
+
let toc = [];
|
|
311
|
+
|
|
312
|
+
if (ncxMatch) {
|
|
313
|
+
const ncxId = ncxMatch[1];
|
|
314
|
+
const ncxHrefMatch = opfXml.match(new RegExp(`id="${ncxId}"[^>]+href="([^"]+)"`)) ||
|
|
315
|
+
opfXml.match(new RegExp(`href="([^"]+)"[^>]+id="${ncxId}"`));
|
|
316
|
+
if (ncxHrefMatch) {
|
|
317
|
+
const ncxPath = opfDir + ncxHrefMatch[1];
|
|
318
|
+
const ncxEntry = zip.getEntry(ncxPath);
|
|
319
|
+
if (ncxEntry) {
|
|
320
|
+
const ncxXml = zip.readAsText(ncxEntry);
|
|
321
|
+
// Very basic NCX parsing using regex
|
|
322
|
+
const navPointRegex = /<navPoint[^>]*>[\s\S]*?<navLabel>[\s\S]*?<text>([\s\S]*?)<\/text>[\s\S]*?<\/navLabel>[\s\S]*?<content src="([^"]+)"/g;
|
|
323
|
+
let match;
|
|
324
|
+
while ((match = navPointRegex.exec(ncxXml)) !== null) {
|
|
325
|
+
let label = match[1].trim();
|
|
326
|
+
let href = match[2];
|
|
327
|
+
// remove anchors
|
|
328
|
+
if (href.includes('#')) href = href.split('#')[0];
|
|
329
|
+
|
|
330
|
+
const fullPath = this._normalizePath(opfDir + href);
|
|
331
|
+
// Avoid duplicates if multiple navPoints point to same file, but keep first (usually better label)
|
|
332
|
+
if (!toc.find(t => t.path === fullPath)) {
|
|
333
|
+
toc.push({
|
|
334
|
+
title: label,
|
|
335
|
+
path: fullPath
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// If no TOC found yet, try EPUB 3 Nav
|
|
344
|
+
if (toc.length === 0) {
|
|
345
|
+
const navMatch = opfXml.match(/properties="[^"]*nav[^"]*"[^>]+href="([^"]+)"/i) ||
|
|
346
|
+
opfXml.match(/href="([^"]+)"[^>]+properties="[^"]*nav[^"]*"/i);
|
|
347
|
+
if (navMatch) {
|
|
348
|
+
const navPath = this._normalizePath(opfDir + navMatch[1]);
|
|
349
|
+
const navEntry = zip.getEntry(navPath);
|
|
350
|
+
if (navEntry) {
|
|
351
|
+
const navXml = zip.readAsText(navEntry);
|
|
352
|
+
// Very basic Nav parsing
|
|
353
|
+
const linkRegex = /<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
354
|
+
let match;
|
|
355
|
+
while ((match = linkRegex.exec(navXml)) !== null) {
|
|
356
|
+
let href = match[1];
|
|
357
|
+
if (href.includes('#')) href = href.split('#')[0];
|
|
358
|
+
const fullPath = this._normalizePath(opfDir + href);
|
|
359
|
+
const label = match[2].replace(/<[^>]+>/g, '').trim();
|
|
360
|
+
if (!toc.find(t => t.path === fullPath)) {
|
|
361
|
+
toc.push({
|
|
362
|
+
title: label,
|
|
363
|
+
path: fullPath
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return toc;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = CalibreClient;
|