dsh-ai-book-shelf 2.6.9 → 2.6.15
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 +22 -9
- package/client.js +142 -1
- package/index.js +128 -12
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
# AI Book Shelf / AI 书架
|
|
2
2
|
|
|
3
3
|
[](https://github.com/0avatar0/dsh-ai-book-shelf)
|
|
4
|
-
[](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.15)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
7
|
A persistent DSH Web UI plugin that adds a full-page multi-format reader to the
|
|
8
|
-
sidebar
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
sidebar, an AI explanation rail for selected text, a paper library with
|
|
9
|
+
translation and bilingual comparison, and a dossier library with searchable
|
|
10
|
+
files and AI summaries.
|
|
11
|
+
|
|
12
|
+
## Highlights
|
|
13
|
+
|
|
14
|
+
- **Read many formats** — EPUB, PDF, TXT, Markdown, HTML, FB2 and DOCX.
|
|
15
|
+
- **Ask without leaving the page** — select text for background, context,
|
|
16
|
+
explanations and follow-ups in the right rail.
|
|
17
|
+
- **Search the whole shelf** — full-book `Ctrl+F`, online book search (Project
|
|
18
|
+
Gutenberg, Standard Ebooks, Chinese Wikimedia sources) and custom OPDS sources.
|
|
19
|
+
- **Paper mode** — paragraph/whole-paper translation, bilingual comparison, AI
|
|
20
|
+
explanations and follow-ups.
|
|
21
|
+
- **Dossier mode** — folders, Ctrl+A multi-import, keyword search, AI summaries
|
|
22
|
+
of results, and per-folder skills.
|
|
11
23
|
|
|
12
24
|
## Install
|
|
13
25
|
|
|
14
|
-
1. Download `dsh-ai-book-shelf-2.6.
|
|
15
|
-
[latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.
|
|
16
|
-
or install from npm with `dsh-ai-book-shelf@2.6.
|
|
26
|
+
1. Download `dsh-ai-book-shelf-2.6.15.tgz` from the
|
|
27
|
+
[latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.15),
|
|
28
|
+
or install from npm with `dsh-ai-book-shelf@2.6.15`.
|
|
17
29
|
2. In DSH open **Plugins → Add plugin** and choose the downloaded tgz, or paste
|
|
18
30
|
the npm package spec.
|
|
19
31
|
3. Reload the plugin and open **AI 书架 / AI Book Shelf** in the sidebar.
|
|
@@ -31,8 +43,9 @@ instructions: [PUBLISHING.md](PUBLISHING.md).
|
|
|
31
43
|
a name collision gets a ` (2)` suffix rather than overwriting anything.
|
|
32
44
|
- **Dossier mode** — a separate dossier library with folders, multi-select
|
|
33
45
|
import (Ctrl/Cmd or Ctrl+A in the file picker), folder-scoped full-text
|
|
34
|
-
search, streamed AI summaries, and folder skills.
|
|
35
|
-
|
|
46
|
+
search, streamed AI summaries, and folder skills. Creating a folder adds an
|
|
47
|
+
empty skill you can fill in; an imported `SKILL.md` can replace it, and AI
|
|
48
|
+
summaries follow it.
|
|
36
49
|
- **Public-domain search** — the **Search books** tab queries Project Gutenberg
|
|
37
50
|
(Gutendex, with the Gutenberg website search as a fallback), a Chinese-only
|
|
38
51
|
Project Gutenberg catalog (`lang=zh`), Chinese Wikisource, Chinese Wikibooks,
|
package/client.js
CHANGED
|
@@ -218,6 +218,22 @@ window.__ModuleLoader__.load({
|
|
|
218
218
|
'reader.next': '下一章',
|
|
219
219
|
'reader.fontSmaller': '减小字号',
|
|
220
220
|
'reader.fontLarger': '放大字号',
|
|
221
|
+
'reader.zoomHint': 'Ctrl / ⌘ + 鼠标滚轮调整字号',
|
|
222
|
+
'reader.zoomHintShort': '+ 滚轮',
|
|
223
|
+
'settings.title': '存储与目录',
|
|
224
|
+
'settings.booksDir': '书籍目录',
|
|
225
|
+
'settings.papersDir': '论文存放目录',
|
|
226
|
+
'settings.dossiersDir': '卷宗存放目录',
|
|
227
|
+
'settings.hint': '留空使用默认路径。修改后返回对应模式重新加载;现有文件不会自动移动。',
|
|
228
|
+
'settings.choose': '选择路径',
|
|
229
|
+
'settings.save': '保存',
|
|
230
|
+
'settings.cancel': '取消',
|
|
231
|
+
'reader.settings': '阅读设置',
|
|
232
|
+
'reader.booksDir': '书籍目录',
|
|
233
|
+
'reader.booksDirHint': '修改后书架会扫描并优先保存到这个目录;留空使用默认目录:{path}',
|
|
234
|
+
'reader.settingsSaved': '书籍目录已保存,返回书架后生效',
|
|
235
|
+
'reader.save': '保存',
|
|
236
|
+
'reader.cancel': '取消',
|
|
221
237
|
'reader.ai': 'AI 解释',
|
|
222
238
|
'reader.find': '全书搜索',
|
|
223
239
|
'reader.findPlaceholder': '搜索全书文字…',
|
|
@@ -466,6 +482,22 @@ window.__ModuleLoader__.load({
|
|
|
466
482
|
'reader.next': 'Next chapter',
|
|
467
483
|
'reader.fontSmaller': 'Smaller text',
|
|
468
484
|
'reader.fontLarger': 'Larger text',
|
|
485
|
+
'reader.zoomHint': 'Ctrl / ⌘ + mouse wheel to resize text',
|
|
486
|
+
'reader.zoomHintShort': '+ wheel',
|
|
487
|
+
'settings.title': 'Storage folders',
|
|
488
|
+
'settings.booksDir': 'Books folder',
|
|
489
|
+
'settings.papersDir': 'Papers folder',
|
|
490
|
+
'settings.dossiersDir': 'Dossiers folder',
|
|
491
|
+
'settings.hint': 'Leave empty for the default path. Reload the matching mode after saving; existing files are not moved.',
|
|
492
|
+
'settings.choose': 'Choose',
|
|
493
|
+
'settings.save': 'Save',
|
|
494
|
+
'settings.cancel': 'Cancel',
|
|
495
|
+
'reader.settings': 'Reader settings',
|
|
496
|
+
'reader.booksDir': 'Books folder',
|
|
497
|
+
'reader.booksDirHint': 'The shelf scans and saves books here. Leave empty for the default: {path}',
|
|
498
|
+
'reader.settingsSaved': 'Books folder saved; return to the shelf to apply',
|
|
499
|
+
'reader.save': 'Save',
|
|
500
|
+
'reader.cancel': 'Cancel',
|
|
469
501
|
'reader.ai': 'Explain',
|
|
470
502
|
'reader.find': 'Find in book',
|
|
471
503
|
'reader.findPlaceholder': 'Search the whole book…',
|
|
@@ -580,6 +612,8 @@ window.__ModuleLoader__.load({
|
|
|
580
612
|
.epub-library-inner { width: min(100%, 1200px); margin: 0 auto; }
|
|
581
613
|
.epub-library-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
|
|
582
614
|
.epub-brand { display: flex; align-items: center; gap: 15px; min-width: 0; }
|
|
615
|
+
.epub-brand-settings { display: grid; place-items: center; width: 34px; height: 34px; flex: none; margin-left: 8px; border: 1px solid var(--epub-line); border-radius: 8px; background: var(--epub-surface); color: var(--epub-muted); }
|
|
616
|
+
.epub-brand-settings:hover { color: var(--epub-text); border-color: var(--epub-line-strong); background: var(--epub-surface-2); }
|
|
583
617
|
.epub-brand-mark {
|
|
584
618
|
width: 52px; height: 52px; flex: none; border-radius: 16px;
|
|
585
619
|
display: grid; place-items: center;
|
|
@@ -762,6 +796,18 @@ window.__ModuleLoader__.load({
|
|
|
762
796
|
.epub-reader-book-title { font-size: 13.5px; line-height: 1.4; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
763
797
|
.epub-reader-book-sub { margin-top: 2px; color: var(--epub-muted); font-size: 10.5px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
764
798
|
.epub-reader-head-right { display: flex; align-items: center; gap: 7px; flex: none; }
|
|
799
|
+
.epub-zoom-hint { display: inline-flex; align-items: center; gap: 2px; color: var(--epub-muted); font-size: 8.5px; white-space: nowrap; }
|
|
800
|
+
.epub-reader-settings { width: min(560px, 100%); padding: 16px; border: 1px solid var(--epub-line-strong); border-radius: 13px; background: var(--epub-surface); box-shadow: 0 22px 60px -18px rgba(0,0,0,.6); }
|
|
801
|
+
.epub-reader-settings header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
|
802
|
+
.epub-reader-settings h3 { margin: 2px 0 0; font-size: 15px; }
|
|
803
|
+
.epub-reader-settings-row { margin-bottom: 9px; }
|
|
804
|
+
.epub-reader-settings-label { display: block; margin-bottom: 5px; color: var(--epub-muted); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; }
|
|
805
|
+
.epub-reader-settings-path { display: flex; align-items: center; gap: 6px; }
|
|
806
|
+
.epub-reader-settings-path .epub-reader-settings-input { flex: 1; min-width: 0; }
|
|
807
|
+
.epub-reader-settings-input { width: 100%; height: 34px; padding: 0 9px; border: 1px solid var(--epub-line); border-radius: 7px; outline: 0; background: var(--dsw-alias-bg-base); color: var(--epub-text); font-size: 11px; }
|
|
808
|
+
.epub-reader-settings-input:focus { border-color: color-mix(in srgb, var(--epub-accent) 45%, var(--epub-line)); }
|
|
809
|
+
.epub-reader-settings-hint { margin: 7px 0 0; color: var(--epub-muted); font-size: 9px; line-height: 1.55; overflow-wrap: anywhere; }
|
|
810
|
+
.epub-reader-settings-error { margin: 8px 0 0; color: var(--epub-danger); font-size: 9.5px; line-height: 1.5; }
|
|
765
811
|
.epub-kbd { margin-left: 3px; padding: 1px 4px; border: 1px solid var(--epub-line); border-radius: 4px; color: var(--epub-muted); font-family: inherit; font-size: 8.5px; font-weight: 500; line-height: 1.4; }
|
|
766
812
|
.epub-find-panel { position: absolute; top: 68px; right: 18px; z-index: 45; display: flex; flex-direction: column; width: min(430px, calc(100% - 36px)); max-height: min(520px, calc(100% - 90px)); border: 1px solid var(--epub-line-strong); border-radius: 12px; background: var(--epub-surface); box-shadow: 0 18px 45px -18px rgba(0,0,0,.55); overflow: hidden; }
|
|
767
813
|
.epub-find-head { display: flex; align-items: center; gap: 8px; padding: 10px; border-bottom: 1px solid var(--epub-line); }
|
|
@@ -1092,6 +1138,7 @@ window.__ModuleLoader__.load({
|
|
|
1092
1138
|
.epub-dossier-search input { width: 100%; min-width: 0; }
|
|
1093
1139
|
.epub-dossier-grid { grid-template-columns: 1fr; }
|
|
1094
1140
|
.epub-dossier-summary { max-height: 88vh; }
|
|
1141
|
+
.epub-zoom-hint { display: none; }
|
|
1095
1142
|
.epub-paper-folders { display: flex; gap: 4px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--epub-line); }
|
|
1096
1143
|
.epub-paper-folder-wrap { flex: none; }
|
|
1097
1144
|
.epub-paper-folder-top { flex: none; }
|
|
@@ -1680,6 +1727,13 @@ window.__ModuleLoader__.load({
|
|
|
1680
1727
|
return iconBase(props ?? {}, [h('path', { key: 'a', d: 'M12 5v14M5 12h14' })])
|
|
1681
1728
|
}
|
|
1682
1729
|
|
|
1730
|
+
function IconSettings(props) {
|
|
1731
|
+
return iconBase(props ?? {}, [
|
|
1732
|
+
h('circle', { key: 'a', cx: 12, cy: 12, r: 3.2 }),
|
|
1733
|
+
h('path', { key: 'b', d: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z' }),
|
|
1734
|
+
])
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1683
1737
|
function IconStopCircle(props) {
|
|
1684
1738
|
return iconBase(props ?? {}, [
|
|
1685
1739
|
h('circle', { key: 'a', cx: 12, cy: 12, r: 8.5 }),
|
|
@@ -1903,6 +1957,12 @@ window.__ModuleLoader__.load({
|
|
|
1903
1957
|
const [dragOver, setDragOver] = React.useState(false)
|
|
1904
1958
|
const [showHidden, setShowHidden] = React.useState(false)
|
|
1905
1959
|
const [bookMenu, setBookMenu] = React.useState(null)
|
|
1960
|
+
const [settingsOpen, setSettingsOpen] = React.useState(false)
|
|
1961
|
+
const [settings, setSettings] = React.useState({ booksDir: '', papersDir: '', dossiersDir: '' })
|
|
1962
|
+
const [settingsDefaults, setSettingsDefaults] = React.useState({ booksDir: '', papersDir: '', dossiersDir: '' })
|
|
1963
|
+
const [settingsSaving, setSettingsSaving] = React.useState(false)
|
|
1964
|
+
const [settingsPicking, setSettingsPicking] = React.useState('')
|
|
1965
|
+
const [settingsError, setSettingsError] = React.useState('')
|
|
1906
1966
|
const fileRef = React.useRef(null)
|
|
1907
1967
|
|
|
1908
1968
|
React.useEffect(() => {
|
|
@@ -2008,6 +2068,60 @@ window.__ModuleLoader__.load({
|
|
|
2008
2068
|
await importFiles([file])
|
|
2009
2069
|
}
|
|
2010
2070
|
|
|
2071
|
+
async function openSettings() {
|
|
2072
|
+
setSettingsOpen(true)
|
|
2073
|
+
setSettingsError('')
|
|
2074
|
+
try {
|
|
2075
|
+
const data = await apiGet('/settings')
|
|
2076
|
+
setSettings({
|
|
2077
|
+
booksDir: data.booksDir ?? '',
|
|
2078
|
+
papersDir: data.papersDir ?? '',
|
|
2079
|
+
dossiersDir: data.dossiersDir ?? '',
|
|
2080
|
+
})
|
|
2081
|
+
setSettingsDefaults({
|
|
2082
|
+
booksDir: data.defaultDir ?? '',
|
|
2083
|
+
papersDir: data.defaultPapersDir ?? '',
|
|
2084
|
+
dossiersDir: data.defaultDossiersDir ?? '',
|
|
2085
|
+
})
|
|
2086
|
+
} catch (reason) {
|
|
2087
|
+
setSettingsError(reason instanceof Error ? reason.message : String(reason))
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
async function chooseSettingsPath(field) {
|
|
2092
|
+
setSettingsPicking(field)
|
|
2093
|
+
setSettingsError('')
|
|
2094
|
+
try {
|
|
2095
|
+
const data = await apiPost('/settings/pick', {})
|
|
2096
|
+
if (typeof data.path === 'string' && data.path !== '') {
|
|
2097
|
+
setSettings((current) => ({ ...current, [field]: data.path }))
|
|
2098
|
+
}
|
|
2099
|
+
} catch (reason) {
|
|
2100
|
+
setSettingsError(reason instanceof Error ? reason.message : String(reason))
|
|
2101
|
+
} finally {
|
|
2102
|
+
setSettingsPicking('')
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
async function saveSettings() {
|
|
2107
|
+
setSettingsSaving(true)
|
|
2108
|
+
setSettingsError('')
|
|
2109
|
+
try {
|
|
2110
|
+
const data = await apiPost('/settings', settings)
|
|
2111
|
+
setSettings({
|
|
2112
|
+
booksDir: data.booksDir ?? '',
|
|
2113
|
+
papersDir: data.papersDir ?? '',
|
|
2114
|
+
dossiersDir: data.dossiersDir ?? '',
|
|
2115
|
+
})
|
|
2116
|
+
setSettingsOpen(false)
|
|
2117
|
+
await load()
|
|
2118
|
+
} catch (reason) {
|
|
2119
|
+
setSettingsError(reason instanceof Error ? reason.message : String(reason))
|
|
2120
|
+
} finally {
|
|
2121
|
+
setSettingsSaving(false)
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2011
2125
|
function saveSourcePrefs(next) {
|
|
2012
2126
|
setSourcePrefs(next)
|
|
2013
2127
|
writeSourcePrefs(next)
|
|
@@ -2175,6 +2289,7 @@ window.__ModuleLoader__.load({
|
|
|
2175
2289
|
h('p', { className: 'epub-subtitle' }, t('library.subtitle')),
|
|
2176
2290
|
h('p', { className: 'epub-formats' }, t('library.supportedFormats')),
|
|
2177
2291
|
),
|
|
2292
|
+
h('button', { type: 'button', className: 'epub-brand-settings', onClick: () => void openSettings(), title: t('settings.title'), 'aria-label': t('settings.title') }, h(IconSettings, { size: 20 })),
|
|
2178
2293
|
),
|
|
2179
2294
|
h(
|
|
2180
2295
|
'div',
|
|
@@ -2466,7 +2581,32 @@ window.__ModuleLoader__.load({
|
|
|
2466
2581
|
})),
|
|
2467
2582
|
),
|
|
2468
2583
|
),
|
|
2469
|
-
|
|
2584
|
+
settingsOpen
|
|
2585
|
+
? h('div', { className: 'epub-dialog-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) setSettingsOpen(false) } },
|
|
2586
|
+
h('section', { className: 'epub-reader-settings' },
|
|
2587
|
+
h('header', null,
|
|
2588
|
+
h('div', null, h('p', { className: 'epub-eyebrow' }, 'STORAGE'), h('h3', null, t('settings.title'))),
|
|
2589
|
+
h('button', { type: 'button', className: 'epub-icon-btn', onClick: () => setSettingsOpen(false), title: t('reader.close') }, h(IconX, { size: 16 })),
|
|
2590
|
+
),
|
|
2591
|
+
[['booksDir', t('settings.booksDir'), settingsDefaults.booksDir], ['papersDir', t('settings.papersDir'), settingsDefaults.papersDir], ['dossiersDir', t('settings.dossiersDir'), settingsDefaults.dossiersDir]].map(([field, label, placeholder]) =>
|
|
2592
|
+
h('div', { className: 'epub-reader-settings-row', key: field },
|
|
2593
|
+
h('label', { className: 'epub-reader-settings-label' }, label),
|
|
2594
|
+
h('div', { className: 'epub-reader-settings-path' },
|
|
2595
|
+
h('input', { className: 'epub-reader-settings-input', value: settings[field], placeholder, onChange: (event) => setSettings({ ...settings, [field]: event.target.value }) }),
|
|
2596
|
+
h('button', { type: 'button', className: 'epub-btn', disabled: settingsPicking !== '', onClick: () => void chooseSettingsPath(field) }, settingsPicking === field ? '…' : t('settings.choose')),
|
|
2597
|
+
),
|
|
2598
|
+
),
|
|
2599
|
+
),
|
|
2600
|
+
h('p', { className: 'epub-reader-settings-hint' }, t('settings.hint')),
|
|
2601
|
+
settingsError !== '' ? h('p', { className: 'epub-reader-settings-error' }, settingsError) : null,
|
|
2602
|
+
h('div', { className: 'epub-dialog-actions' },
|
|
2603
|
+
h('button', { type: 'button', className: 'epub-btn', onClick: () => setSettingsOpen(false) }, t('settings.cancel')),
|
|
2604
|
+
h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void saveSettings(), disabled: settingsSaving }, settingsSaving ? '…' : t('settings.save')),
|
|
2605
|
+
),
|
|
2606
|
+
),
|
|
2607
|
+
)
|
|
2608
|
+
: null,
|
|
2609
|
+
bookMenu !== null
|
|
2470
2610
|
? h('div', { className: 'epub-context-menu', style: { left: `${bookMenu.x}px`, top: `${bookMenu.y}px` }, onClick: (event) => event.stopPropagation() },
|
|
2471
2611
|
h('button', { type: 'button', className: 'epub-context-item', onClick: () => { const selected = bookMenu.book; setBookMenu(null); void toggleHidden(selected) } }, bookMenu.book.hidden === true ? t('library.unhide') : t('library.hide')),
|
|
2472
2612
|
h('button', {
|
|
@@ -4818,6 +4958,7 @@ window.__ModuleLoader__.load({
|
|
|
4818
4958
|
h('button', { type: 'button', onClick: () => updateFont(-1), title: t('reader.fontSmaller'), 'aria-label': t('reader.fontSmaller') }, h(IconMinus, { size: 13 })),
|
|
4819
4959
|
h('button', { type: 'button', onClick: () => updateFont(1), title: t('reader.fontLarger'), 'aria-label': t('reader.fontLarger') }, h(IconPlus, { size: 13 })),
|
|
4820
4960
|
),
|
|
4961
|
+
h('span', { className: 'epub-zoom-hint', title: t('reader.zoomHint') }, h('kbd', { className: 'epub-kbd' }, 'Ctrl'), t('reader.zoomHintShort')),
|
|
4821
4962
|
h('button', { type: 'button', className: cn('epub-ghost-btn', tocOpen && 'is-active'), onClick: () => setTocOpen((value) => !value), title: t('reader.toc'), 'aria-label': t('reader.toc') }, h(IconList, { size: 15 }), h('span', null, t('reader.toc'))),
|
|
4822
4963
|
h('button', { type: 'button', className: 'epub-ai-trigger', onClick: () => setAi((current) => ({ ...current, open: !current.open })), title: t('reader.ai'), 'aria-label': t('reader.ai') }, h(IconSparkles, { size: 15 }), h('span', null, t('reader.ai'))),
|
|
4823
4964
|
),
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* credential.
|
|
8
8
|
*/
|
|
9
9
|
import { createHash } from 'node:crypto'
|
|
10
|
-
import { promises as fs } from 'node:fs'
|
|
10
|
+
import { promises as fs, readFileSync } from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { BookError, EpubError, parseBook } from './lib/books.js'
|
|
13
13
|
import { stripTags } from './lib/sanitize.js'
|
|
@@ -24,6 +24,9 @@ const MAX_SCAN_DEPTH = 6
|
|
|
24
24
|
const BOOK_EXTENSIONS = new Set([
|
|
25
25
|
'.epub', '.pdf', '.txt', '.text', '.md', '.markdown', '.html', '.htm', '.xhtml', '.fb2', '.docx',
|
|
26
26
|
])
|
|
27
|
+
let activeBooksDir = ''
|
|
28
|
+
let activePapersDir = ''
|
|
29
|
+
let activeDossiersDir = ''
|
|
27
30
|
const SEARCH_TTL_MS = 30 * 60 * 1000
|
|
28
31
|
const MAX_SEARCH_RESULTS = 24
|
|
29
32
|
const DOWNLOAD_TIMEOUT_MS = 180_000
|
|
@@ -167,7 +170,7 @@ function firstWorkspaceRoot(ctx) {
|
|
|
167
170
|
return process.cwd()
|
|
168
171
|
}
|
|
169
172
|
|
|
170
|
-
function
|
|
173
|
+
function defaultArchiveDir(config, ctx) {
|
|
171
174
|
const configuredArchive = typeof config?.archiveDir === 'string' ? config.archiveDir.trim() : ''
|
|
172
175
|
if (configuredArchive !== '') return path.resolve(configuredArchive)
|
|
173
176
|
const configured = configuredLibraryDirs(config)
|
|
@@ -178,11 +181,19 @@ function resolveArchiveDir(config, ctx) {
|
|
|
178
181
|
return path.resolve(firstWorkspaceRoot(ctx))
|
|
179
182
|
}
|
|
180
183
|
|
|
184
|
+
function resolveArchiveDir(config, ctx) {
|
|
185
|
+
return defaultArchiveDir(config, ctx)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveBooksDir(config, ctx) {
|
|
189
|
+
return activeBooksDir !== '' ? path.resolve(activeBooksDir) : defaultArchiveDir(config, ctx)
|
|
190
|
+
}
|
|
191
|
+
|
|
181
192
|
function normalizeRoots(config, ctx) {
|
|
182
193
|
const configured = configuredLibraryDirs(config)
|
|
183
194
|
// Scan only the archive shelf by default. External books enter the shelf
|
|
184
195
|
// through Open EPUB, which copies them into the archive before opening.
|
|
185
|
-
if (configured.length === 0) configured.push(
|
|
196
|
+
if (configured.length === 0) configured.push(resolveBooksDir(config, ctx))
|
|
186
197
|
const unique = []
|
|
187
198
|
for (const root of configured) {
|
|
188
199
|
const resolved = path.resolve(root)
|
|
@@ -231,6 +242,37 @@ async function ensureArchiveDir(config, ctx) {
|
|
|
231
242
|
}
|
|
232
243
|
}
|
|
233
244
|
|
|
245
|
+
async function ensureBooksDir(config, ctx) {
|
|
246
|
+
const directory = resolveBooksDir(config, ctx)
|
|
247
|
+
try {
|
|
248
|
+
await fs.mkdir(directory, { recursive: true })
|
|
249
|
+
return await fs.realpath(directory)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw new HttpError(500, 'BOOKS_DIR_UNAVAILABLE', `无法创建书籍目录 ${directory}:${publicErrorMessage(error)}`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readerSettingsPath(config, ctx) {
|
|
256
|
+
return path.join(defaultArchiveDir(config, ctx), ANNOTATION_DIRECTORY, 'settings.json')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readReaderSettingsSync(config, ctx) {
|
|
260
|
+
try {
|
|
261
|
+
const value = JSON.parse(readFileSync(readerSettingsPath(config, ctx), 'utf8'))
|
|
262
|
+
return value !== null && typeof value === 'object' ? value : {}
|
|
263
|
+
} catch {
|
|
264
|
+
return {}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function writeReaderSettings(config, ctx, settings) {
|
|
269
|
+
const file = readerSettingsPath(config, ctx)
|
|
270
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
271
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
272
|
+
await fs.writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
|
|
273
|
+
await fs.rename(temporary, file)
|
|
274
|
+
}
|
|
275
|
+
|
|
234
276
|
/**
|
|
235
277
|
* Put one EPUB into the archive directory. An identical file already stored
|
|
236
278
|
* under the same name is reused; a name collision with different content gets
|
|
@@ -1356,6 +1398,16 @@ async function openModelStream(ctx, route, payload, signal) {
|
|
|
1356
1398
|
}
|
|
1357
1399
|
|
|
1358
1400
|
function createHandler(ctx, config) {
|
|
1401
|
+
const savedSettings = readReaderSettingsSync(config, ctx)
|
|
1402
|
+
activeBooksDir = typeof savedSettings.booksDir === 'string' && savedSettings.booksDir.trim() !== ''
|
|
1403
|
+
? path.resolve(savedSettings.booksDir.trim())
|
|
1404
|
+
: ''
|
|
1405
|
+
activePapersDir = typeof savedSettings.papersDir === 'string' && savedSettings.papersDir.trim() !== ''
|
|
1406
|
+
? path.resolve(savedSettings.papersDir.trim())
|
|
1407
|
+
: ''
|
|
1408
|
+
activeDossiersDir = typeof savedSettings.dossiersDir === 'string' && savedSettings.dossiersDir.trim() !== ''
|
|
1409
|
+
? path.resolve(savedSettings.dossiersDir.trim())
|
|
1410
|
+
: ''
|
|
1359
1411
|
const store = new BookStore(MAX_OPEN_BOOKS)
|
|
1360
1412
|
let catalog = new Map()
|
|
1361
1413
|
const searchCache = new Map()
|
|
@@ -1438,9 +1490,9 @@ function createHandler(ctx, config) {
|
|
|
1438
1490
|
throw new HttpError(500, 'READ_FAILED', `读取失败:${publicErrorMessage(error)}`)
|
|
1439
1491
|
}
|
|
1440
1492
|
|
|
1441
|
-
const
|
|
1442
|
-
if (!isInside(
|
|
1443
|
-
return await ingestBuffer(buffer, entry.name,
|
|
1493
|
+
const booksDir = await ensureBooksDir(config, ctx)
|
|
1494
|
+
if (!isInside(booksDir, entry.path)) {
|
|
1495
|
+
return await ingestBuffer(buffer, entry.name, booksDir)
|
|
1444
1496
|
}
|
|
1445
1497
|
|
|
1446
1498
|
let book
|
|
@@ -1465,7 +1517,7 @@ function createHandler(ctx, config) {
|
|
|
1465
1517
|
return { book, copied: false, created: false, copyPath: '' }
|
|
1466
1518
|
}
|
|
1467
1519
|
|
|
1468
|
-
function paperRootFor(archiveDir) { return path.join(archiveDir, '.epub-reader', 'papers') }
|
|
1520
|
+
function paperRootFor(archiveDir) { return activePapersDir !== '' ? path.resolve(activePapersDir) : path.join(archiveDir, '.epub-reader', 'papers') }
|
|
1469
1521
|
|
|
1470
1522
|
async function ensurePaperDirs(archiveDir) {
|
|
1471
1523
|
const root = paperRootFor(archiveDir)
|
|
@@ -1805,7 +1857,7 @@ function createHandler(ctx, config) {
|
|
|
1805
1857
|
}
|
|
1806
1858
|
|
|
1807
1859
|
function dossierRootFor(archiveDir) {
|
|
1808
|
-
return path.join(archiveDir, '.epub-reader', 'dossiers')
|
|
1860
|
+
return activeDossiersDir !== '' ? path.resolve(activeDossiersDir) : path.join(archiveDir, '.epub-reader', 'dossiers')
|
|
1809
1861
|
}
|
|
1810
1862
|
|
|
1811
1863
|
async function ensureDossierDirs(archiveDir) {
|
|
@@ -2228,8 +2280,8 @@ function createHandler(ctx, config) {
|
|
|
2228
2280
|
}
|
|
2229
2281
|
const buffer = await readBody(req, MAX_IMPORT_BYTES)
|
|
2230
2282
|
if (buffer.length === 0) throw new HttpError(400, 'EMPTY_FILE', '没有收到文件内容')
|
|
2231
|
-
const
|
|
2232
|
-
return await ingestBuffer(buffer, name,
|
|
2283
|
+
const booksDir = await ensureBooksDir(config, ctx)
|
|
2284
|
+
return await ingestBuffer(buffer, name, booksDir)
|
|
2233
2285
|
}
|
|
2234
2286
|
|
|
2235
2287
|
function readSearchOptions(url) {
|
|
@@ -2331,12 +2383,12 @@ function createHandler(ctx, config) {
|
|
|
2331
2383
|
? lastError
|
|
2332
2384
|
: new HttpError(502, 'DOWNLOAD_FAILED', '下载失败,请稍后重试')
|
|
2333
2385
|
}
|
|
2334
|
-
const
|
|
2386
|
+
const booksDir = await ensureBooksDir(config, ctx)
|
|
2335
2387
|
if (downloadFileName === '') {
|
|
2336
2388
|
const author = item.author === '' ? '' : ` - ${item.author}`
|
|
2337
2389
|
downloadFileName = `${item.title}${author}.epub`
|
|
2338
2390
|
}
|
|
2339
|
-
const result = await ingestBuffer(buffer, downloadFileName,
|
|
2391
|
+
const result = await ingestBuffer(buffer, downloadFileName, booksDir)
|
|
2340
2392
|
return { ...result, source: item.source, sourceName: item.sourceName }
|
|
2341
2393
|
}
|
|
2342
2394
|
|
|
@@ -2437,6 +2489,70 @@ function createHandler(ctx, config) {
|
|
|
2437
2489
|
return
|
|
2438
2490
|
}
|
|
2439
2491
|
|
|
2492
|
+
if (method === 'GET' && pathname === '/settings') {
|
|
2493
|
+
const base = defaultArchiveDir(config, ctx)
|
|
2494
|
+
sendJson(res, 200, {
|
|
2495
|
+
booksDir: resolveBooksDir(config, ctx),
|
|
2496
|
+
papersDir: activePapersDir !== '' ? activePapersDir : path.join(base, '.epub-reader', 'papers'),
|
|
2497
|
+
dossiersDir: activeDossiersDir !== '' ? activeDossiersDir : path.join(base, '.epub-reader', 'dossiers'),
|
|
2498
|
+
defaultDir: base,
|
|
2499
|
+
defaultPapersDir: path.join(base, '.epub-reader', 'papers'),
|
|
2500
|
+
defaultDossiersDir: path.join(base, '.epub-reader', 'dossiers'),
|
|
2501
|
+
})
|
|
2502
|
+
return
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
if (method === 'POST' && pathname === '/settings/pick') {
|
|
2506
|
+
const picker = ctx.get('directoryPickerController')
|
|
2507
|
+
if (picker === undefined || typeof picker.pick !== 'function') {
|
|
2508
|
+
throw new HttpError(503, 'PICKER_UNAVAILABLE', '当前环境没有可用的目录选择器,请直接输入路径')
|
|
2509
|
+
}
|
|
2510
|
+
await readJson(req)
|
|
2511
|
+
const controller = new AbortController()
|
|
2512
|
+
res.on('close', () => { if (!res.writableEnded) controller.abort() })
|
|
2513
|
+
const selected = await picker.pick(controller.signal)
|
|
2514
|
+
sendJson(res, 200, { path: typeof selected === 'string' ? selected : '' })
|
|
2515
|
+
return
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
if (method === 'POST' && pathname === '/settings') {
|
|
2519
|
+
const payload = await readJson(req)
|
|
2520
|
+
const fields = [
|
|
2521
|
+
['booksDir', '书籍目录', 'BOOKS_DIR_UNAVAILABLE'],
|
|
2522
|
+
['papersDir', '论文存放目录', 'PAPERS_DIR_UNAVAILABLE'],
|
|
2523
|
+
['dossiersDir', '卷宗存放目录', 'DOSSIERS_DIR_UNAVAILABLE'],
|
|
2524
|
+
]
|
|
2525
|
+
const next = {}
|
|
2526
|
+
for (const [field, label, code] of fields) {
|
|
2527
|
+
if (typeof payload[field] !== 'string') continue
|
|
2528
|
+
const requested = payload[field].trim()
|
|
2529
|
+
if (requested === '') { next[field] = ''; continue }
|
|
2530
|
+
const target = path.resolve(requested)
|
|
2531
|
+
try {
|
|
2532
|
+
await fs.mkdir(target, { recursive: true })
|
|
2533
|
+
next[field] = await fs.realpath(target)
|
|
2534
|
+
} catch (error) {
|
|
2535
|
+
throw new HttpError(400, code, `无法使用${label} ${target}:${publicErrorMessage(error)}`)
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
const current = readReaderSettingsSync(config, ctx)
|
|
2539
|
+
await writeReaderSettings(config, ctx, { ...current, ...next })
|
|
2540
|
+
if ('booksDir' in next) activeBooksDir = next.booksDir
|
|
2541
|
+
if ('papersDir' in next) activePapersDir = next.papersDir
|
|
2542
|
+
if ('dossiersDir' in next) activeDossiersDir = next.dossiersDir
|
|
2543
|
+
catalog = new Map()
|
|
2544
|
+
const base = defaultArchiveDir(config, ctx)
|
|
2545
|
+
sendJson(res, 200, {
|
|
2546
|
+
booksDir: resolveBooksDir(config, ctx),
|
|
2547
|
+
papersDir: activePapersDir !== '' ? activePapersDir : path.join(base, '.epub-reader', 'papers'),
|
|
2548
|
+
dossiersDir: activeDossiersDir !== '' ? activeDossiersDir : path.join(base, '.epub-reader', 'dossiers'),
|
|
2549
|
+
defaultDir: base,
|
|
2550
|
+
defaultPapersDir: path.join(base, '.epub-reader', 'papers'),
|
|
2551
|
+
defaultDossiersDir: path.join(base, '.epub-reader', 'dossiers'),
|
|
2552
|
+
})
|
|
2553
|
+
return
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2440
2556
|
if (method === 'GET' && pathname === '/books') {
|
|
2441
2557
|
const roots = normalizeRoots(config, ctx)
|
|
2442
2558
|
const result = await scanLibrary(roots)
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
2
|
-
"meta": {
|
|
3
|
-
"title": "AI Book Shelf",
|
|
4
|
-
"description": "AI Book Shelf: read
|
|
5
|
-
}
|
|
6
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "AI Book Shelf",
|
|
4
|
+
"description": "AI Book Shelf: read EPUB, PDF, TXT, Markdown, HTML, FB2 and DOCX; select text for one-click background and explanations, then follow up without leaving the page; full-book search, online book search and custom sources; paper mode with translation, bilingual compare, explanations and follow-ups; dossier mode with batch import, keyword search, AI summaries and skills."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
2
|
-
"meta": {
|
|
3
|
-
"title": "AI 书架",
|
|
4
|
-
"description": "AI
|
|
5
|
-
}
|
|
6
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "AI 书架",
|
|
4
|
+
"description": "AI 书架:支持 EPUB、PDF、TXT、Markdown、HTML、FB2、DOCX 阅读;选中文字即可一键获取背景解释、作者省略的解释并继续追问,无需切换界面;支持全文搜索、在线搜书和自定义书源;论文模式支持翻译、中英对照、解释与追问;卷宗模式支持批量导入、关键词搜索、AI 总结搜索结果和 Skill。"
|
|
5
|
+
}
|
|
6
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ai-book-shelf",
|
|
3
|
-
"version": "2.6.
|
|
4
|
-
"description": "AI Book Shelf: read
|
|
3
|
+
"version": "2.6.15",
|
|
4
|
+
"description": "AI Book Shelf: read EPUB, PDF, TXT, Markdown, HTML, FB2 and DOCX; select text for one-click background and explanations, then follow up without leaving the page; full-book search, online book search and custom sources; paper mode with translation, bilingual compare, explanations and follow-ups; dossier mode with batch import, keyword search, AI summaries and skills.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"ai-book-shelf",
|
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
"pdf",
|
|
13
13
|
"reader",
|
|
14
14
|
"ai",
|
|
15
|
-
"deepseek-harness"
|
|
15
|
+
"deepseek-harness",
|
|
16
|
+
"dossier",
|
|
17
|
+
"dossier-mode",
|
|
18
|
+
"case-file",
|
|
19
|
+
"online-book-search",
|
|
20
|
+
"custom-opds",
|
|
21
|
+
"full-text-search"
|
|
16
22
|
],
|
|
17
23
|
"repository": {
|
|
18
24
|
"type": "git",
|