diff --git a/addon/content/preferences.xhtml b/addon/content/preferences.xhtml
index 5e3f456..4443a0c 100644
--- a/addon/content/preferences.xhtml
+++ b/addon/content/preferences.xhtml
@@ -177,6 +177,23 @@
preference="mineruPath"
>
+
+
+
+
+
+
diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl
index d3f3d7c..29590db 100644
--- a/addon/locale/en-US/addon.ftl
+++ b/addon/locale/en-US/addon.ftl
@@ -135,3 +135,4 @@ lecture-tts-provider = TTS: { $provider }
lecture-tts-test = Test voice
lecture-tts-testing = Synthesizing test speech…
lecture-tts-test-done = Speech ready — playing
+lecture-storage-write-failed = Cannot write voice lecture data to { $path }. { $detail } Set “Data directory” under Edit → Settings → ChatPapers → Voice lecture (must be writable).
diff --git a/addon/locale/en-US/preferences.ftl b/addon/locale/en-US/preferences.ftl
index 4586908..25576fc 100644
--- a/addon/locale/en-US/preferences.ftl
+++ b/addon/locale/en-US/preferences.ftl
@@ -34,4 +34,6 @@ prefs-tts-voice = Voice (optional)
prefs-piper-path = Piper executable
prefs-piper-model = Piper model path (.onnx)
prefs-mineru-path = MinerU path
+prefs-lecture-data-dir = Data directory
+prefs-lecture-data-dir-hint = Leave empty to use chatpapers/lecture under your Zotero profile. If writes fail, set a writable absolute path, e.g. D:\ChatPapers\lecture-data
prefs-tts-hint-empty = Choose a TTS engine. Local engines are fully offline — no API key needed.
diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl
index d7ebf13..a2ef57d 100644
--- a/addon/locale/zh-CN/addon.ftl
+++ b/addon/locale/zh-CN/addon.ftl
@@ -135,3 +135,4 @@ lecture-tts-provider = TTS 引擎:{ $provider }
lecture-tts-test = 试听语音
lecture-tts-testing = 正在合成测试语音…
lecture-tts-test-done = 语音合成完成,正在播放
+lecture-storage-write-failed = 无法写入语音伴读数据目录:{ $path }。{ $detail } 请在 编辑 → 设置 → ChatPapers → 语音伴读 中配置「数据存储目录」(需有写入权限)。
diff --git a/addon/locale/zh-CN/preferences.ftl b/addon/locale/zh-CN/preferences.ftl
index 8d950a3..1603f19 100644
--- a/addon/locale/zh-CN/preferences.ftl
+++ b/addon/locale/zh-CN/preferences.ftl
@@ -34,4 +34,6 @@ prefs-tts-voice = 音色(可选)
prefs-piper-path = Piper 可执行文件
prefs-piper-model = Piper 模型路径 (.onnx)
prefs-mineru-path = MinerU 路径
+prefs-lecture-data-dir = 数据存储目录
+prefs-lecture-data-dir-hint = 留空则使用 Zotero 配置文件夹下的 chatpapers/lecture。若写入失败,请填写有写入权限的绝对路径,例如 D:\ChatPapers\lecture-data
prefs-tts-hint-empty = 请选择 TTS 引擎。本地引擎完全离线,无需 API Key。
diff --git a/addon/prefs.js b/addon/prefs.js
index d9540e9..7fa47c5 100644
--- a/addon/prefs.js
+++ b/addon/prefs.js
@@ -20,5 +20,6 @@ pref("lectureTeacherStyle", "balanced");
pref("mineruPath", "");
pref("piperPath", "");
pref("piperModelPath", "");
+pref("lectureDataDir", "");
pref("chatPaneAdaptiveHeight", true);
pref("lecturePaneAdaptiveHeight", true);
diff --git a/src/modules/lecture/infrastructure/platform/paths.ts b/src/modules/lecture/infrastructure/platform/paths.ts
index f4f40ca..c3a167e 100644
--- a/src/modules/lecture/infrastructure/platform/paths.ts
+++ b/src/modules/lecture/infrastructure/platform/paths.ts
@@ -22,6 +22,43 @@ export function joinPath(...parts: string[]): string {
return PathUtils.join(...parts.filter(Boolean));
}
+/** Normalize a filesystem path for the current OS. */
+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)
+ .split("/")
+ .filter((part) => part && part !== ".")
+ .join("\\");
+ return `${drive}\\${rest}`.replace(/\\+$/, "");
+ }
+ return joined
+ .split("/")
+ .filter((part) => part && part !== ".")
+ .join("/")
+ .replace(/\/+$/, "");
+}
+
+/** Safe single path segment for file or directory names (cross-platform). */
+export function sanitizePathSegment(input: string, maxLen = 120): string {
+ const cleaned = String(input || "")
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
+ .replace(/[.\s]+$/, "")
+ .trim();
+ if (!cleaned) return "_";
+ if (cleaned.length <= maxLen) return cleaned;
+ let hash = 0;
+ for (let i = 0; i < input.length; i++) {
+ hash = (hash * 31 + input.charCodeAt(i)) | 0;
+ }
+ const tail = Math.abs(hash).toString(36);
+ return `${cleaned.slice(0, Math.max(1, maxLen - tail.length - 1))}_${tail}`;
+}
+
/** Convert local file path to file:// URL for