From af2ee38443324c65370b6338a06fbeb9fdccfae0 Mon Sep 17 00:00:00 2001 From: yhy Date: Sun, 30 Aug 2026 09:02:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95=E8=8E=B7?= =?UTF-8?q?=E5=BE=97pdf=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/locale/en-US/addon.ftl | 4 + addon/locale/zh-CN/addon.ftl | 4 + .../lecture/infrastructure/platform/paths.ts | 36 ++++-- .../lecture/infrastructure/storage/paths.ts | 21 +--- .../lecture/infrastructure/zotero/adapter.ts | 111 ++++++++++++++++-- src/modules/lecture/ui/lecturePane.ts | 16 ++- src/modules/storage/sessions.ts | 4 +- typings/i10n.d.ts | 4 + 8 files changed, 151 insertions(+), 49 deletions(-) diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl index 29590db..e015509 100644 --- a/addon/locale/en-US/addon.ftl +++ b/addon/locale/en-US/addon.ftl @@ -126,6 +126,10 @@ lecture-playing-zh = Playing: translation lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced lecture-no-pdf = No PDF attachment on this item lecture-attach-error = Cannot read PDF attachment +lecture-attach-error-detail = Cannot read PDF attachment: { $detail } +lecture-pdf-not-local = PDF is not available locally. Open the PDF in Zotero first, or ensure the attachment is synced or linked to a local file. +lecture-pdf-file-missing = Local PDF file is missing or the path is invalid. Check Zotero storage settings or re-link the file. +lecture-pdf-read-failed = Cannot read PDF file contents. Check that the file is valid and readable. lecture-mount-error = Failed to load voice lecture pane lecture-platform-hint-mac = macOS: set MinerU path under Preferences → ChatPapers → Voice lecture, or leave empty to use mineru on PATH. lecture-platform-hint-win = Windows: set the full path to mineru.exe under Preferences → ChatPapers → Voice lecture. diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl index a2ef57d..d849f8a 100644 --- a/addon/locale/zh-CN/addon.ftl +++ b/addon/locale/zh-CN/addon.ftl @@ -126,6 +126,10 @@ lecture-playing-zh = 正在播放:中文讲解 lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步 lecture-no-pdf = 当前条目没有可用的 PDF 附件 lecture-attach-error = 无法读取 PDF 附件 +lecture-attach-error-detail = 无法读取 PDF 附件:{ $detail } +lecture-pdf-not-local = PDF 未下载到本地。请先在 Zotero 中双击打开该 PDF,或确认附件已同步/已链接到本机文件。 +lecture-pdf-file-missing = PDF 本地文件不存在或路径无效。请检查 Zotero 存储设置,或重新关联 PDF 文件。 +lecture-pdf-read-failed = 无法读取 PDF 文件内容。请确认文件未损坏,且 Zotero 有读取权限。 lecture-mount-error = 语音伴读面板加载失败 lecture-platform-hint-mac = macOS:MinerU 路径可在偏好设置 → ChatPapers → 语音伴读 中配置;留空则尝试 PATH 中的 mineru。 lecture-platform-hint-win = Windows:请在偏好设置中填写 MinerU 可执行文件完整路径(如 mineru.exe)。 diff --git a/src/modules/lecture/infrastructure/platform/paths.ts b/src/modules/lecture/infrastructure/platform/paths.ts index c3a167e..b616113 100644 --- a/src/modules/lecture/infrastructure/platform/paths.ts +++ b/src/modules/lecture/infrastructure/platform/paths.ts @@ -19,28 +19,38 @@ export function normalizeExecutablePath(input: string): string { /** Join path segments using Zotero PathUtils (cross-platform). */ export function joinPath(...parts: string[]): string { - return PathUtils.join(...parts.filter(Boolean)); + const filtered = parts.filter(Boolean); + if (!filtered.length) return ""; + try { + return PathUtils.join(...filtered); + } catch (e) { + ztoolkit.log("[ChatPapers] PathUtils.join failed", filtered, e); + throw e; + } } -/** Normalize a filesystem path for the current OS. */ +/** Normalize user-provided storage directory paths (prefs). */ export function normalizeStoragePath(input: string): string { const trimmed = (input || "").trim(); if (!trimmed) return ""; - const joined = trimmed.replace(/[\\/]+/g, "/"); - if (/^[A-Za-z]:\//.test(joined)) { - const drive = joined.slice(0, 2); - const rest = joined - .slice(2) + + const unified = trimmed.replace(/\\/g, "/").replace(/\/+/g, "/"); + + const winMatch = unified.match(/^([A-Za-z]:)(\/.*)?$/); + if (winMatch) { + const drive = winMatch[1]; + const rest = (winMatch[2] || "") .split("/") .filter((part) => part && part !== ".") .join("\\"); - return `${drive}\\${rest}`.replace(/\\+$/, ""); + return rest ? `${drive}\\${rest}` : drive; } - return joined - .split("/") - .filter((part) => part && part !== ".") - .join("/") - .replace(/\/+$/, ""); + + const absolute = unified.startsWith("/"); + const parts = unified.split("/").filter((part) => part && part !== "."); + let result = parts.join("/"); + if (absolute) result = `/${result}`; + return result.replace(/\/+$/, "") || (absolute ? "/" : ""); } /** Safe single path segment for file or directory names (cross-platform). */ diff --git a/src/modules/lecture/infrastructure/storage/paths.ts b/src/modules/lecture/infrastructure/storage/paths.ts index 9bd2350..93013fb 100644 --- a/src/modules/lecture/infrastructure/storage/paths.ts +++ b/src/modules/lecture/infrastructure/storage/paths.ts @@ -7,25 +7,10 @@ import { } from "../platform/paths"; function resolveProfileDir(): string { - const z = Zotero as any; - const candidates: unknown[] = [ - z.Profile?.dir, - typeof z.getProfileDirectory === "function" ? z.getProfileDirectory() : undefined, - ]; - - for (const candidate of candidates) { - const path = - typeof candidate === "string" - ? candidate - : candidate && - typeof candidate === "object" && - "path" in candidate && - typeof (candidate as { path: unknown }).path === "string" - ? (candidate as { path: string }).path - : ""; - if (path.trim()) return normalizeStoragePath(path.trim()); + const dir = (Zotero as any).Profile?.dir; + if (typeof dir === "string" && dir.trim()) { + return dir.trim(); } - throw new Error("Cannot resolve Zotero profile directory"); } diff --git a/src/modules/lecture/infrastructure/zotero/adapter.ts b/src/modules/lecture/infrastructure/zotero/adapter.ts index 4afdf7e..0a7a793 100644 --- a/src/modules/lecture/infrastructure/zotero/adapter.ts +++ b/src/modules/lecture/infrastructure/zotero/adapter.ts @@ -1,4 +1,5 @@ import { findPdfAttachment } from "../../../pdf/extractor"; +import { getString } from "../../../../utils/locale"; import type { PaperAttachmentContext } from "../../domain/types"; import { buildCacheKey, @@ -6,12 +7,93 @@ import { paperIdFromCacheKey, } from "../pdf/fileHash"; -function attachmentFilePath(attachment: Zotero.Item): string { - const path = attachment.getFilePath?.() as string | false; - if (!path) { - throw new Error("PDF attachment has no local file path"); +async function attachmentFilePath(attachment: Zotero.Item): Promise { + const att = attachment as Zotero.Item & { + getFilePath?: () => string | false; + getFilePathAsync?: () => Promise; + }; + + if (typeof att.getFilePathAsync === "function") { + try { + const asyncPath = await att.getFilePathAsync(); + if (asyncPath && typeof asyncPath === "string") { + return asyncPath; + } + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] getFilePathAsync failed", e); + } + } + + const syncPath = att.getFilePath?.(); + if (syncPath && typeof syncPath === "string") { + return syncPath; + } + + const zAtt = (Zotero as any).Attachments; + if (zAtt?.getFilePath) { + try { + const path = zAtt.getFilePath(attachment); + if (path && typeof path === "string") return path; + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] Attachments.getFilePath failed", e); + } + } + + throw new Error("PDF_NOT_LOCAL"); +} + +async function attachmentFileHash( + attachment: Zotero.Item, + filePath: string, +): Promise { + try { + return await computeFileHash(filePath); + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] computeFileHash failed", filePath, e); + const att = attachment as Zotero.Item & { + attachmentHash?: Promise; + }; + if (att.attachmentHash) { + try { + const md5 = await att.attachmentHash; + if (md5 && typeof md5 === "string") { + return `md5:${md5}`; + } + } catch (hashErr) { + ztoolkit.log("[ChatPapers:Lecture] attachmentHash fallback failed", hashErr); + } + } + throw new Error("PDF_READ_FAILED"); + } +} + +async function ensureAttachmentFileReadable(filePath: string): Promise { + try { + if (await IOUtils.exists(filePath)) return; + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] IOUtils.exists failed", filePath, e); + } + throw new Error("PDF_FILE_MISSING"); +} + +export function formatLectureAttachmentError(error: unknown): string { + if (!(error instanceof Error)) { + return getString("lecture-attach-error"); + } + switch (error.message) { + case "NO_PDF": + return getString("lecture-no-pdf"); + case "PDF_NOT_LOCAL": + return getString("lecture-pdf-not-local"); + case "PDF_FILE_MISSING": + return getString("lecture-pdf-file-missing"); + case "PDF_READ_FAILED": + return getString("lecture-pdf-read-failed"); + default: + return getString("lecture-attach-error-detail", { + args: { detail: error.message }, + }); } - return path; } export async function resolvePaperAttachment( @@ -22,13 +104,22 @@ export async function resolvePaperAttachment( throw new Error("NO_PDF"); } - const filePath = attachmentFilePath(attachment); - const exists = await IOUtils.exists(filePath); - if (!exists) { - throw new Error("PDF_FILE_MISSING"); + let filePath: string; + try { + filePath = await attachmentFilePath(attachment); + } catch (e) { + if (e instanceof Error && e.message === "PDF_NOT_LOCAL") throw e; + ztoolkit.log("[ChatPapers:Lecture] resolve attachment path failed", { + attachmentId: attachment.id, + linkMode: (attachment as any).attachmentLinkMode, + e, + }); + throw new Error("PDF_NOT_LOCAL"); } - const fileHash = await computeFileHash(filePath); + await ensureAttachmentFileReadable(filePath); + + const fileHash = await attachmentFileHash(attachment, filePath); const attachmentId = String(attachment.id); const cacheKey = buildCacheKey(attachmentId, fileHash); diff --git a/src/modules/lecture/ui/lecturePane.ts b/src/modules/lecture/ui/lecturePane.ts index 2bf9cf4..f6e9228 100644 --- a/src/modules/lecture/ui/lecturePane.ts +++ b/src/modules/lecture/ui/lecturePane.ts @@ -38,6 +38,7 @@ import { TtsError } from "../infrastructure/tts/types"; import { getRegularItem, resolvePaperAttachment, + formatLectureAttachmentError, } from "../infrastructure/zotero/adapter"; import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash"; import { chunkById } from "../infrastructure/pdf/chunker"; @@ -517,11 +518,8 @@ export class LecturePaneView { } await this.refreshPlayer(); } catch (e) { - const msg = - e instanceof Error && e.message === "NO_PDF" - ? getString("lecture-no-pdf") - : getString("lecture-attach-error"); - this.setStatus("failed", msg); + ztoolkit.log("[ChatPapers:Lecture] refreshFromStore failed", e); + this.setStatus("failed", formatLectureAttachmentError(e)); this.actionBtn.disabled = true; this.ttsTestBtn.disabled = true; } @@ -1358,7 +1356,13 @@ export class LecturePaneView { this.setStatus(this.paper.lectureStatus, msg); await this.refreshPlayer(); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + ztoolkit.log("[ChatPapers:Lecture] onStartPrepare failed", e); + const msg = + e instanceof Error && e.message.startsWith("PDF_") + ? formatLectureAttachmentError(e) + : e instanceof Error + ? e.message + : String(e); this.setStatus("failed", msg); } finally { this.preparing = false; diff --git a/src/modules/storage/sessions.ts b/src/modules/storage/sessions.ts index 345383a..df57b93 100644 --- a/src/modules/storage/sessions.ts +++ b/src/modules/storage/sessions.ts @@ -1,8 +1,8 @@ import type { ChatMessage } from "../llm/types"; function sessionDir(): string { - const profile = (Zotero as any).Profile?.dir || (Zotero as any).getProfileDirectory?.()?.path; - if (!profile) { + const profile = (Zotero as any).Profile?.dir; + if (!profile || typeof profile !== "string") { throw new Error("Cannot resolve Zotero profile directory"); } return PathUtils.join(profile, "chatpapers", "sessions"); diff --git a/typings/i10n.d.ts b/typings/i10n.d.ts index 72cbbca..fa660ec 100644 --- a/typings/i10n.d.ts +++ b/typings/i10n.d.ts @@ -44,6 +44,7 @@ export type FluentMessageId = | 'itemmenu-compare' | 'itemmenu-label' | 'lecture-attach-error' + | 'lecture-attach-error-detail' | 'lecture-beat-detail-empty' | 'lecture-beat-label' | 'lecture-beat-list-hint' @@ -71,6 +72,9 @@ export type FluentMessageId = | 'lecture-para-play' | 'lecture-para-translation' | 'lecture-paragraph-list-title' + | 'lecture-pdf-file-missing' + | 'lecture-pdf-not-local' + | 'lecture-pdf-read-failed' | 'lecture-pdf-sync-hint' | 'lecture-platform-hint-mac' | 'lecture-platform-hint-other'