Update plugin 隐阅盒 v1.3.2#267
Conversation
- feat: 初始化ZTools插件项目 - feat: 实现隐阅盒阅读器插件 - chore(gitignore): add ignore rules - chore: 相关标识重命名 - chore: add changelog - fix: 修复书架导入书籍异常未捕获、完善本地存储数据兼容逻辑 - docs: 更新README - docs: 重写README - feat: 发布1.1.0版本,新增多项功能并修复多个bug - fix(窗口拖动): 修复阅读窗口拖动拉伸卡顿问题 - chore: bump plugin version to 1.1.1 - chore: 重命名部分称呼 - feat(setting): add plain cover option, remove epub cache storage - feat: 新增MOBI电子书格式支持 - feat: 迁移封面和章节缓存到ztools.db - feat: 新增主题模式切换功能,优化深色主题样式 - chore: release v1.3.0, update plugin info and changelog - style: 优化窗口进度显示 - chore: 发布v1.3.1版本,更新功能与修复问题 - chore: bump plugin version to 1.3.1 - fix: 修复多个已知问题并优化部分功能 - style(settings): 优化设置项的封面提示文案 - fix(bookshelf): 修复纯色封面关闭后MOBI封面未恢复的问题 - docs: 更新README中的截图 - feat(bookshelf): 实现拖拽导入书籍功能 - feat(bookshelf): 增加快捷导入命令 - chore: update gitignore and changelog - feat(tips): 增加提示文本 ,更新版本标识 - feat: 新增多选批量操作、重载元数据和恢复封面功能 - style(plugin.json): 调整插件命令列表的排序顺序 - feat: 新增书籍信息弹窗与阅读数据追踪,优化元数据解析
There was a problem hiding this comment.
Code Review
This pull request introduces several features to the HushReader plugin, including drag-and-drop book import, a shortcut import command, multi-select batch operations, and a detailed book information modal with reading statistics. The review feedback highlights critical issues that should be addressed: a logic defect in reading duration tracking that incorrectly accumulates idle time, a performance bottleneck where MOBI covers are repeatedly parsed due to temporary blob URLs, and potential metadata loss if file reading fails during reload. Additionally, the reviewer suggests fixing an interaction bug in the category editor and replacing unsafe non-null assertions with computed properties for better robustness.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function saveReadingProgress() { | ||
| const book = bookStore.currentBook | ||
| if (!book) return | ||
| bookStore.updateBook(book.id, { | ||
| const now = Date.now() | ||
| const updates: Partial<typeof book> = { | ||
| lastChapter: readerStore.currentChapterIndex, | ||
| progressIndex: readerStore.progressIndex, | ||
| lastReadAt: Date.now(), | ||
| lastReadAt: now, | ||
| totalChapters: readerStore.chapters.length, | ||
| readingPercent: readerStore.readingPercent | ||
| }) | ||
| } | ||
| if (!book.firstReadAt) { | ||
| updates.firstReadAt = now | ||
| } | ||
| if (book.lastReadAt && book.lastReadAt > 0) { | ||
| const elapsed = now - book.lastReadAt | ||
| if (elapsed > 0 && elapsed < 30 * 60 * 1000) { | ||
| const totalMs = (book.readingTimeMs || 0) + elapsed | ||
| updates.readingTimeMs = totalMs | ||
| const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0) | ||
| const currentReadChars = Math.round(totalChars * (readerStore.readingPercent / 100)) | ||
| const prevReadChars = book.lastSaveReadChars || 0 | ||
| const deltaChars = Math.max(0, currentReadChars - prevReadChars) | ||
| updates.lastSaveReadChars = currentReadChars | ||
| const elapsedMinutes = elapsed / 60000 | ||
| if (elapsedMinutes > 0 && deltaChars > 0) { | ||
| const sessionSpeed = deltaChars / elapsedMinutes | ||
| const prevSpeed = book.readingSpeed || 0 | ||
| if (prevSpeed > 0) { | ||
| updates.readingSpeed = Math.round(prevSpeed * 0.6 + sessionSpeed * 0.4) | ||
| } else { | ||
| updates.readingSpeed = Math.round(sessionSpeed) | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0) | ||
| updates.lastSaveReadChars = Math.round(totalChars * (readerStore.readingPercent / 100)) | ||
| } | ||
| bookStore.updateBook(book.id, updates) | ||
| } |
There was a problem hiding this comment.
💡 阅读时长与速度统计逻辑缺陷
当前代码使用 book.lastReadAt 来计算两次保存之间的间隔时间 elapsed。由于 lastReadAt 是持久化存储的上一次保存时间(可能来自几天前的上一次阅读会话),这会导致以下两个严重问题:
- 新会话的第一页阅读时间丢失:当用户开启新一次阅读时,
elapsed通常会大于 30 分钟,从而触发else分支,导致新会话第一页的阅读时间完全没有被记录。 - 闲置/关闭期间的时间被错误计入:如果用户关闭插件并在 30 分钟内重新打开,
elapsed会小于 30 分钟,从而将插件关闭期间的闲置时间错误地累加到readingTimeMs中,导致阅读时长虚高,且阅读速度计算极度偏低。
🛠️ 解决方案
引入一个基于内存的会话级活跃时间戳 __hushreaderSessionLastActive。当检测到切换书籍或首次打开时,重置该时间戳。这样可以确保 elapsed 仅精确测量当前会话中的实际阅读时间。
function saveReadingProgress() {
const book = bookStore.currentBook
if (!book) return
const now = Date.now()
const updates: Partial<typeof book> = {
lastChapter: readerStore.currentChapterIndex,
progressIndex: readerStore.progressIndex,
lastReadAt: now,
totalChapters: readerStore.chapters.length,
readingPercent: readerStore.readingPercent
}
if (!book.firstReadAt) {
updates.firstReadAt = now
}
// 初始化或重置会话计时器,避免将跨会话的闲置时间计入阅读时长
if ((window as any).__hushreaderSessionBookId !== book.id) {
(window as any).__hushreaderSessionBookId = book.id
(window as any).__hushreaderSessionLastActive = now
}
const lastActive = (window as any).__hushreaderSessionLastActive || now
const elapsed = now - lastActive
if (elapsed > 0 && elapsed < 30 * 60 * 1000) {
const totalMs = (book.readingTimeMs || 0) + elapsed
updates.readingTimeMs = totalMs
const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0)
const currentReadChars = Math.round(totalChars * (readerStore.readingPercent / 100))
const prevReadChars = book.lastSaveReadChars || 0
const deltaChars = Math.max(0, currentReadChars - prevReadChars)
updates.lastSaveReadChars = currentReadChars
const elapsedMinutes = elapsed / 60000
if (elapsedMinutes > 0 && deltaChars > 0) {
const sessionSpeed = deltaChars / elapsedMinutes
const prevSpeed = book.readingSpeed || 0
if (prevSpeed > 0) {
updates.readingSpeed = Math.round(prevSpeed * 0.6 + sessionSpeed * 0.4)
} else {
updates.readingSpeed = Math.round(sessionSpeed)
}
}
} else {
const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0)
updates.lastSaveReadChars = Math.round(totalChars * (readerStore.readingPercent / 100))
}
(window as any).__hushreaderSessionLastActive = now
bookStore.updateBook(book.id, updates)
}
| description: string | ||
| chapters: Chapter[] | ||
| coverUrl?: string | ||
| encrypted?: boolean |
There was a problem hiding this comment.
⚡ MOBI 封面缓存失效与重复解析性能问题
在 parseMobi 中,封面图片是通过 URL.createObjectURL(blob) 生成的 blob: URL 返回的。
blob: URL 是会话特定的,在应用重启后会失效。这会导致以下连锁反应:
- 应用重启后,书架加载缓存的
blob:URL 失败,触发@error="repairCover"。 repairCover检测到格式为mobi,直接清空并删除了该封面缓存。- 随后
resolveMobiCovers发现该书没有封面,于是在每次启动时都重新读取并解析 MOBI 文件来生成新的blob:URL。
这会导致每次打开书架时,所有 MOBI 书籍都会被重新解析一次,造成严重的 CPU 和磁盘 I/O 开销。
🛠️ 解决方案
建议将 extractCoverUrl 修改为同步将图片字节转换为 Base64 Data URL(类似于 EPUB 的处理方式),以便永久缓存到 IndexedDB 中。
请在 mobiParser.ts 中将 extractCoverUrl 函数修改为:
function extractCoverUrl(
data: Uint8Array,
recordOffsets: number[],
firstImageIndex: number,
exthRecords: EXTHRecord[]
): string | undefined {
try {
const coverRecord = findExthRecord(exthRecords, 201)
if (!coverRecord) return undefined
if (firstImageIndex === 0xffffffff) return undefined
const coverRecordIndex = firstImageIndex + exthRawToUint(coverRecord.raw)
const start = recordOffsets[coverRecordIndex]
const end = recordOffsets[coverRecordIndex + 1] ?? data.length
if (start === undefined || start >= data.length || end > data.length || start >= end) return undefined
const imageBytes = data.slice(start, end)
const mime = detectImageMime(imageBytes)
if (!mime) return undefined
// 同步转换为 Base64,确保可以被 IndexedDB 永久缓存
let binary = ''
const len = imageBytes.byteLength
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(imageBytes[i])
}
return `data:${mime};base64,${btoa(binary)}`
} catch {
return undefined
}
}| async function reloadMetadata(bookId: string, silent = false) { | ||
| const book = bookStore.books.find(b => b.id === bookId) | ||
| if (!book) return | ||
|
|
||
| try { | ||
| let title = book.title | ||
| let author = book.author | ||
| let description = book.description || '' | ||
| let coverImage: string | undefined | ||
| let totalChapters: number | undefined | ||
|
|
||
| if (book.format === 'epub') { | ||
| const content = window.services?.readFileBinary?.(book.filePath) | ||
| if (content) { | ||
| const blob = new Blob([content], { type: 'application/epub+zip' }) | ||
| const file = new File([blob], book.filePath.split(/[\\/]/).pop() ?? 'book.epub') | ||
| const result = await parseEpub(file) | ||
| title = result.title || title | ||
| author = result.author || author | ||
| description = result.description || description | ||
| totalChapters = result.chapters?.length | ||
| if (result.coverUrl && !configStore.config.other.plainTextCover) coverImage = result.coverUrl | ||
| if (result.chapters?.length) saveChapters(bookId, result.chapters).catch(() => {}) | ||
| } | ||
| } else if (book.format === 'mobi') { | ||
| const content = window.services?.readFileBinary?.(book.filePath) | ||
| if (content) { | ||
| const blob = new Blob([content], { type: 'application/x-mobipocket-ebook' }) | ||
| const file = new File([blob], book.filePath.split(/[\\/]/).pop() ?? 'book.mobi') | ||
| const result = await parseMobi(file) | ||
| if (!result.error) { | ||
| title = result.title || title | ||
| author = result.author || author | ||
| description = result.description || description | ||
| totalChapters = result.chapters?.length | ||
| if (result.coverUrl && !configStore.config.other.plainTextCover) coverImage = result.coverUrl | ||
| if (result.chapters?.length) saveChapters(bookId, result.chapters).catch(() => {}) | ||
| } | ||
| } | ||
| } else { | ||
| const text = window.services?.readFile(book.filePath) ?? '' | ||
| const chapters = parseTxt(text, configStore.config.other.chapterRegex || undefined) | ||
| totalChapters = chapters.length | ||
| if (chapters.length) saveChapters(bookId, chapters).catch(() => {}) | ||
| } | ||
|
|
||
| const fileModifiedAt = window.services?.getFileModifiedTime?.(book.filePath) | ||
| const updates: Partial<typeof book> = { title, author, description: description || undefined, totalChapters, fileModifiedAt, customCoverImage: undefined } | ||
|
|
||
| removeCustomCover(bookId).catch(() => {}) | ||
|
|
||
| if (coverImage) { | ||
| updates.coverImage = coverImage | ||
| saveCover(bookId, coverImage).catch(() => {}) | ||
| } else { | ||
| updates.coverImage = undefined | ||
| removeCover(bookId).catch(() => {}) | ||
| } | ||
|
|
||
| bookStore.updateBook(bookId, updates) | ||
| if (!silent) toast(`《${title}》元数据已重载`, 'success') | ||
| } catch (e: any) { | ||
| if (!silent) toast(`重载失败:${e.message}`, 'error') | ||
| throw e | ||
| } | ||
| } |
There was a problem hiding this comment.
⚠️ 文件读取失败时导致元数据被静默清空
在 reloadMetadata 中,如果书籍文件暂时不可读(例如外部存储未挂载、文件被占用或路径失效),window.services?.readFileBinary 或 readFile 会返回 undefined 或空值。
此时代码并不会抛出异常,而是会静默继续执行,并使用 undefined 覆盖原有的 coverImage、customCoverImage 和 totalChapters,从而彻底抹除该书籍已有的封面和章节数。
🛠️ 解决方案
在读取文件内容后,应当增加非空校验。如果读取失败,应立即抛出错误中断重载流程,避免破坏已有的元数据。
async function reloadMetadata(bookId: string, silent = false) {
const book = bookStore.books.find(b => b.id === bookId)
if (!book) return
try {
let title = book.title
let author = book.author
let description = book.description || ''
let coverImage: string | undefined
let totalChapters: number | undefined
if (book.format === 'epub') {
const content = window.services?.readFileBinary?.(book.filePath)
if (!content) {
throw new Error('无法读取文件,请检查文件是否存在或路径是否正确')
}
const blob = new Blob([content], { type: 'application/epub+zip' })
const file = new File([blob], book.filePath.split(/[\\/]/).pop() ?? 'book.epub')
const result = await parseEpub(file)
title = result.title || title
author = result.author || author
description = result.description || description
totalChapters = result.chapters?.length
if (result.coverUrl && !configStore.config.other.plainTextCover) coverImage = result.coverUrl
if (result.chapters?.length) saveChapters(bookId, result.chapters).catch(() => {})
} else if (book.format === 'mobi') {
const content = window.services?.readFileBinary?.(book.filePath)
if (!content) {
throw new Error('无法读取文件,请检查文件是否存在或路径是否正确')
}
const blob = new Blob([content], { type: 'application/x-mobipocket-ebook' })
const file = new File([blob], book.filePath.split(/[\\/]/).pop() ?? 'book.mobi')
const result = await parseMobi(file)
if (result.error) { throw new Error(result.error) }
title = result.title || title
author = result.author || author
description = result.description || description
totalChapters = result.chapters?.length
if (result.coverUrl && !configStore.config.other.plainTextCover) coverImage = result.coverUrl
if (result.chapters?.length) saveChapters(bookId, result.chapters).catch(() => {})
} else {
const text = window.services?.readFile(book.filePath)
if (text === undefined || text === null) {
throw new Error('无法读取文件,请检查文件是否存在或路径是否正确')
}
const chapters = parseTxt(text, configStore.config.other.chapterRegex || undefined)
totalChapters = chapters.length
if (chapters.length) saveChapters(bookId, chapters).catch(() => {})
}
const fileModifiedAt = window.services?.getFileModifiedTime?.(book.filePath)
const updates: Partial<typeof book> = { title, author, description: description || undefined, totalChapters, fileModifiedAt, customCoverImage: undefined }
removeCustomCover(bookId).catch(() => {})
if (coverImage) {
updates.coverImage = coverImage
saveCover(bookId, coverImage).catch(() => {})
} else {
updates.coverImage = undefined
removeCover(bookId).catch(() => {})
}
bookStore.updateBook(bookId, updates)
if (!silent) toast(`《${title}》元数据已重载`, 'success')
} catch (e: any) {
if (!silent) toast(`重载失败:${e.message}`, 'error')
throw e
}
}
| function saveEdit() { | ||
| const newCats = editNewCategory.value | ||
| .split(/[,,]/) | ||
| .map(s => s.trim()) | ||
| .filter(Boolean) | ||
| const finalCats = [...new Set([...editCategories.value, ...newCats])] | ||
|
|
||
| const updates: Partial<Book> = { | ||
| title: editTitle.value.trim() || '未命名', | ||
| author: editAuthor.value.trim(), | ||
| description: editDescription.value.trim() || undefined, | ||
| categories: finalCats.length > 0 ? finalCats : undefined, | ||
| updatedAt: Date.now() | ||
| } | ||
| emit('saved', updates) | ||
| isEditing.value = false | ||
| } |
There was a problem hiding this comment.
🔍 新增分类交互缺陷与死代码
在当前实现中,editCategories.filter(c => !existingCategories.includes(c)) 用于过滤并展示新输入的分类标签。
然而,existingCategories 包含了书架中所有书籍的分类(包括当前书籍)。这意味着:
- 当前书籍已有的分类必然存在于
existingCategories中。 - 用户在输入框
editNewCategory中输入的新分类,在点击“保存”前并不会被添加到editCategories数组中。
因此,这个过滤条件永远会返回空数组,导致 .edit-category-tags 区域成为永远无法渲染的死代码。
🛠️ 解决方案
实现一个 addCustomCategory 函数,当用户在输入框中输入分类并按下回车、逗号或失去焦点时,立即将其添加到 editCategories 中。这样新分类就能正确渲染为可删除的标签,且交互体验更佳。
function addCustomCategory() {
const val = editNewCategory.value.trim()
if (!val) return
const cats = val.split(/[,,]/).map(s => s.trim()).filter(Boolean)
cats.forEach(c => {
if (!editCategories.value.includes(c)) {
editCategories.value.push(c)
}
})
editNewCategory.value = ''
}
function saveEdit() {
addCustomCategory()
const finalCats = [...new Set(editCategories.value)]
const updates: Partial<Book> = {
title: editTitle.value.trim() || '未命名',
author: editAuthor.value.trim(),
description: editDescription.value.trim() || undefined,
categories: finalCats.length > 0 ? finalCats : undefined,
updatedAt: Date.now()
}
emit('saved', updates)
isEditing.value = false
}
| <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> | ||
| </button> | ||
| </div> | ||
| <input v-model="editNewCategory" class="edit-input" placeholder="新分类,逗号分隔..." style="margin-top: 8px" /> |
There was a problem hiding this comment.
| // Book info modal | ||
| const showBookInfo = ref(false) | ||
| const bookInfoId = ref<string | null>(null) | ||
|
|
||
| function openBookInfo(bookId: string) { | ||
| closeContextMenu() | ||
| bookInfoId.value = bookId | ||
| showBookInfo.value = true | ||
| } |
There was a problem hiding this comment.
🛡️ 避免非空断言,提升组件安全性
在模板中直接使用 bookStore.books.find(...)! 配合非空断言存在安全隐患。如果书籍在后台被删除或列表更新,可能会导致运行时崩溃。
建议使用 computed 属性来安全地获取当前选中的书籍对象。
// Book info modal
const showBookInfo = ref(false)
const bookInfoId = ref<string | null>(null)
const bookInfoBook = computed(() => bookStore.books.find(b => b.id === bookInfoId.value))
function openBookInfo(bookId: string) {
closeContextMenu()
bookInfoId.value = bookId
showBookInfo.value = true
}
| <BookInfoModal | ||
| v-if="showBookInfo && bookInfoId" | ||
| :book="bookStore.books.find(b => b.id === bookInfoId)!" | ||
| @close="showBookInfo = false" | ||
| @saved="onBookInfoSaved" | ||
| /> |
- feat: 初始化ZTools插件项目 - feat: 实现隐阅盒阅读器插件 - chore(gitignore): add ignore rules - chore: 相关标识重命名 - chore: add changelog - fix: 修复书架导入书籍异常未捕获、完善本地存储数据兼容逻辑 - docs: 更新README - docs: 重写README - feat: 发布1.1.0版本,新增多项功能并修复多个bug - fix(窗口拖动): 修复阅读窗口拖动拉伸卡顿问题 - chore: bump plugin version to 1.1.1 - chore: 重命名部分称呼 - feat(setting): add plain cover option, remove epub cache storage - feat: 新增MOBI电子书格式支持 - feat: 迁移封面和章节缓存到ztools.db - feat: 新增主题模式切换功能,优化深色主题样式 - chore: release v1.3.0, update plugin info and changelog - style: 优化窗口进度显示 - chore: 发布v1.3.1版本,更新功能与修复问题 - chore: bump plugin version to 1.3.1 - fix: 修复多个已知问题并优化部分功能 - style(settings): 优化设置项的封面提示文案 - fix(bookshelf): 修复纯色封面关闭后MOBI封面未恢复的问题 - docs: 更新README中的截图 - feat(bookshelf): 实现拖拽导入书籍功能 - feat(bookshelf): 增加快捷导入命令 - chore: update gitignore and changelog - feat(tips): 增加提示文本 ,更新版本标识 - feat: 新增多选批量操作、重载元数据和恢复封面功能 - style(plugin.json): 调整插件命令列表的排序顺序 - feat: 新增书籍信息弹窗与阅读数据追踪,优化元数据解析 - refactor: 完成bug修复 ### 详细变更 1. 修复MOBI封面解析:将Blob URL替换为可持久化的Base64格式,解决IndexedDB缓存失效问题 2. 优化书籍导入查重逻辑:通过文件名匹配避免重复导入同书籍 3. 重构阅读时长与速度统计:新增会话计时器避免跨会话闲置时间被计入,修复首页阅读时间丢失问题 4. 完善元数据加载容错:文件读取失败时不再静默清空原有元数据,抛出明确错误提示 5. 优化书籍信息编辑交互:新增分类批量添加、快捷键触发功能,修复分类选择UI缺陷
插件信息
本次变更
Added
拖拽导入书籍:将书籍文件拖入书架界面时,显示拖拽悬停覆盖层提示"导入书籍",松手后弹出确认弹窗列出待导入文件,确认后解析书籍并保存元数据,取消则放弃导入
快捷文件导入:支持通过
hushreader-import命令从 ztools 快捷导入书籍文件,onPluginEnter触发时自动解析并添加到书架TXT 章节正则提示气泡:设置页"TXT 章节识别正则"标签旁新增 ⓘ 提示图标,悬停/聚焦时显示默认规则说明
重载元数据:右键菜单新增"重载元数据"选项,重新解析书籍的章节、封面、标题、作者等信息并更新,同时清除自定义封面
恢复封面:右键菜单新增"恢复封面"选项,EPUB/MOBI 删除自定义封面并重新从文件加载封面,TXT 删除自定义封面恢复为纯色封面
多选模式:书架头部新增多选按钮,点击进入多选模式,可逐个勾选书籍或通过分类栏"全选"按钮批量选中当前分类下所有书籍
批量操作:多选模式下底部显示操作栏,支持批量重载元数据和批量删除,批量删除前弹出确认弹窗
书籍信息窗口:右键菜单新增"书籍信息"选项,打开信息窗口展示封面与标题作者、简介、分类、阅读信息(导入/更新/首次阅读/最近阅读时间、阅读时长、阅读速度);支持编辑模式可上传自定义封面、编辑标题/作者/简介、选择已有分类或新建分类
EPUB/MOBI 简介解析:导入和重载元数据时从 EPUB metadata.description 和 MOBI EXTH record 101 提取书籍简介
阅读进度追踪:保存阅读进度时记录首次阅读时间(firstReadAt)、累计阅读时长(readingTimeMs)、阅读速度(readingSpeed)
updatedAt 时间戳:书籍新增时设置 updatedAt = addedAt,编辑元数据或上传封面时更新 updatedAt,分类变更不更新 updatedAt
Fixed
截图 / 演示
自检清单
plugins/hushreader/目录此 PR 由 ztools-plugin-cli 自动管理:每次
ztools publish在分支上追加一个 commit,PR 链接保持不变。