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 CHANGED
@@ -1,19 +1,31 @@
1
1
  # AI Book Shelf / AI 书架
2
2
 
3
3
  [![GitHub](https://img.shields.io/badge/GitHub-0avatar0%2Fdsh--ai--book--shelf-181717?logo=github)](https://github.com/0avatar0/dsh-ai-book-shelf)
4
- [![Release](https://img.shields.io/badge/release-v2.6.9-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.9)
4
+ [![Release](https://img.shields.io/badge/release-v2.6.15-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.15)
5
5
  [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
6
 
7
7
  A persistent DSH Web UI plugin that adds a full-page multi-format reader to the
8
- sidebar and an AI explanation rail for selected text. It also includes a
9
- separate paper library with paragraph/whole-paper translation and bilingual
10
- comparison.
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.9.tgz` from the
15
- [latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.9),
16
- or install from npm with `dsh-ai-book-shelf@2.6.9`.
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. A Markdown file named
35
- Creating a folder adds an empty skill you can fill in; an imported `SKILL.md` can replace it, and AI summaries follow it.
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
- bookMenu !== null
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 resolveArchiveDir(config, ctx) {
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(resolveArchiveDir(config, ctx))
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 archiveDir = await ensureArchiveDir(config, ctx)
1442
- if (!isInside(archiveDir, entry.path)) {
1443
- return await ingestBuffer(buffer, entry.name, archiveDir)
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 archiveDir = await ensureArchiveDir(config, ctx)
2232
- return await ingestBuffer(buffer, name, archiveDir)
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 archiveDir = await ensureArchiveDir(config, ctx)
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, archiveDir)
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 books and papers, search full text, ask AI with follow-ups, and translate papers side by side."
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 书架:阅读 EPUB/PDF 与论文,全文搜索、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.9",
4
- "description": "AI Book Shelf: read books and papers in Harness, search full text, explain and discuss with AI, translate papers and compare bilingual text.",
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",