修正windows下无法保存的问题
This commit is contained in:
@@ -177,6 +177,23 @@
|
|||||||
preference="mineruPath"
|
preference="mineruPath"
|
||||||
></html:input>
|
></html:input>
|
||||||
</hbox>
|
</hbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-lecture-data-dir"
|
||||||
|
data-l10n-id="prefs-lecture-data-dir"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-lecture-data-dir"
|
||||||
|
preference="lectureDataDir"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<html:p
|
||||||
|
id="chatpapers-pref-lecture-data-dir-hint"
|
||||||
|
class="chatpapers-pref-hint"
|
||||||
|
data-l10n-id="prefs-lecture-data-dir-hint"
|
||||||
|
></html:p>
|
||||||
</groupbox>
|
</groupbox>
|
||||||
|
|
||||||
<groupbox>
|
<groupbox>
|
||||||
|
|||||||
@@ -135,3 +135,4 @@ lecture-tts-provider = TTS: { $provider }
|
|||||||
lecture-tts-test = Test voice
|
lecture-tts-test = Test voice
|
||||||
lecture-tts-testing = Synthesizing test speech…
|
lecture-tts-testing = Synthesizing test speech…
|
||||||
lecture-tts-test-done = Speech ready — playing
|
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).
|
||||||
|
|||||||
@@ -34,4 +34,6 @@ prefs-tts-voice = Voice (optional)
|
|||||||
prefs-piper-path = Piper executable
|
prefs-piper-path = Piper executable
|
||||||
prefs-piper-model = Piper model path (.onnx)
|
prefs-piper-model = Piper model path (.onnx)
|
||||||
prefs-mineru-path = MinerU path
|
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.
|
prefs-tts-hint-empty = Choose a TTS engine. Local engines are fully offline — no API key needed.
|
||||||
|
|||||||
@@ -135,3 +135,4 @@ lecture-tts-provider = TTS 引擎:{ $provider }
|
|||||||
lecture-tts-test = 试听语音
|
lecture-tts-test = 试听语音
|
||||||
lecture-tts-testing = 正在合成测试语音…
|
lecture-tts-testing = 正在合成测试语音…
|
||||||
lecture-tts-test-done = 语音合成完成,正在播放
|
lecture-tts-test-done = 语音合成完成,正在播放
|
||||||
|
lecture-storage-write-failed = 无法写入语音伴读数据目录:{ $path }。{ $detail } 请在 编辑 → 设置 → ChatPapers → 语音伴读 中配置「数据存储目录」(需有写入权限)。
|
||||||
|
|||||||
@@ -34,4 +34,6 @@ prefs-tts-voice = 音色(可选)
|
|||||||
prefs-piper-path = Piper 可执行文件
|
prefs-piper-path = Piper 可执行文件
|
||||||
prefs-piper-model = Piper 模型路径 (.onnx)
|
prefs-piper-model = Piper 模型路径 (.onnx)
|
||||||
prefs-mineru-path = MinerU 路径
|
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。
|
prefs-tts-hint-empty = 请选择 TTS 引擎。本地引擎完全离线,无需 API Key。
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ pref("lectureTeacherStyle", "balanced");
|
|||||||
pref("mineruPath", "");
|
pref("mineruPath", "");
|
||||||
pref("piperPath", "");
|
pref("piperPath", "");
|
||||||
pref("piperModelPath", "");
|
pref("piperModelPath", "");
|
||||||
|
pref("lectureDataDir", "");
|
||||||
pref("chatPaneAdaptiveHeight", true);
|
pref("chatPaneAdaptiveHeight", true);
|
||||||
pref("lecturePaneAdaptiveHeight", true);
|
pref("lecturePaneAdaptiveHeight", true);
|
||||||
|
|||||||
@@ -22,6 +22,43 @@ export function joinPath(...parts: string[]): string {
|
|||||||
return PathUtils.join(...parts.filter(Boolean));
|
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 <audio src>. */
|
/** Convert local file path to file:// URL for <audio src>. */
|
||||||
export function pathToFileUrl(filePath: string): string {
|
export function pathToFileUrl(filePath: string): string {
|
||||||
const normalized = filePath.replace(/\\/g, "/");
|
const normalized = filePath.replace(/\\/g, "/");
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { BeatType, SummaryJson } from "../../domain/types";
|
import type { BeatType, SummaryJson } from "../../domain/types";
|
||||||
import type { TextChunk } from "../pdf/chunker";
|
import type { TextChunk } from "../pdf/chunker";
|
||||||
import { joinPath } from "../platform/paths";
|
import {
|
||||||
import { ensureLectureDirs, lectureDataRoot } from "./paths";
|
ensureLectureDirs,
|
||||||
|
lectureDataFilePath,
|
||||||
|
} from "./paths";
|
||||||
|
|
||||||
export interface StoredBeat {
|
export interface StoredBeat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,8 +75,7 @@ export interface LectureData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lectureDataPath(cacheKey: string): string {
|
function lectureDataPath(cacheKey: string): string {
|
||||||
const safe = cacheKey.replace(/[^a-zA-Z0-9_|.-]/g, "_");
|
return lectureDataFilePath(cacheKey);
|
||||||
return joinPath(lectureDataRoot(), "data", `${safe}.json`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLectureData(
|
export async function loadLectureData(
|
||||||
@@ -94,11 +95,6 @@ export async function loadLectureData(
|
|||||||
|
|
||||||
export async function saveLectureData(data: LectureData): Promise<void> {
|
export async function saveLectureData(data: LectureData): Promise<void> {
|
||||||
await ensureLectureDirs();
|
await ensureLectureDirs();
|
||||||
const dir = joinPath(lectureDataRoot(), "data");
|
|
||||||
await IOUtils.makeDirectory(dir, {
|
|
||||||
createAncestors: true,
|
|
||||||
ignoreExisting: true,
|
|
||||||
});
|
|
||||||
data.updatedAt = Date.now();
|
data.updatedAt = Date.now();
|
||||||
await IOUtils.writeUTF8(
|
await IOUtils.writeUTF8(
|
||||||
lectureDataPath(data.cacheKey),
|
lectureDataPath(data.cacheKey),
|
||||||
|
|||||||
@@ -1,18 +1,50 @@
|
|||||||
import { joinPath } from "../platform/paths";
|
import { getPref } from "../../../../utils/prefs";
|
||||||
|
import { getString } from "../../../../utils/locale";
|
||||||
|
import {
|
||||||
|
joinPath,
|
||||||
|
normalizeStoragePath,
|
||||||
|
sanitizePathSegment,
|
||||||
|
} from "../platform/paths";
|
||||||
|
|
||||||
function profileDir(): string {
|
function resolveProfileDir(): string {
|
||||||
const profile =
|
const z = Zotero as any;
|
||||||
(Zotero as any).Profile?.dir ||
|
const candidates: unknown[] = [
|
||||||
(Zotero as any).getProfileDirectory?.()?.path;
|
z.Profile?.dir,
|
||||||
if (!profile) {
|
typeof z.getProfileDirectory === "function" ? z.getProfileDirectory() : undefined,
|
||||||
throw new Error("Cannot resolve Zotero profile directory");
|
];
|
||||||
|
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
return profile;
|
|
||||||
|
throw new Error("Cannot resolve Zotero profile directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `{Profile}/chatpapers/lecture/` — voice lecture data root */
|
function customLectureDataDir(): string {
|
||||||
|
return String(getPref("lectureDataDir") || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map logical cacheKey (may contain `|`) to a filesystem-safe directory/file stem. */
|
||||||
|
export function cacheKeyDirName(cacheKey: string): string {
|
||||||
|
return sanitizePathSegment(cacheKey, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved voice-lecture data root (custom pref or `{Profile}/chatpapers/lecture/`). */
|
||||||
export function lectureDataRoot(): string {
|
export function lectureDataRoot(): string {
|
||||||
return joinPath(profileDir(), "chatpapers", "lecture");
|
const custom = customLectureDataDir();
|
||||||
|
if (custom) {
|
||||||
|
return normalizeStoragePath(custom);
|
||||||
|
}
|
||||||
|
return joinPath(resolveProfileDir(), "chatpapers", "lecture");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lectureDbPath(): string {
|
export function lectureDbPath(): string {
|
||||||
@@ -24,12 +56,16 @@ export function lectureIndexPath(): string {
|
|||||||
return joinPath(lectureDataRoot(), "cache-index.json");
|
return joinPath(lectureDataRoot(), "cache-index.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function lectureDataFilePath(cacheKey: string): string {
|
||||||
|
return joinPath(lectureDataRoot(), "data", `${cacheKeyDirName(cacheKey)}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
export function audioRootDir(): string {
|
export function audioRootDir(): string {
|
||||||
return joinPath(lectureDataRoot(), "audio");
|
return joinPath(lectureDataRoot(), "audio");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function audioDirForPaper(cacheKey: string): string {
|
export function audioDirForPaper(cacheKey: string): string {
|
||||||
return joinPath(audioRootDir(), cacheKey);
|
return joinPath(audioRootDir(), cacheKeyDirName(cacheKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function audioFilePath(
|
export function audioFilePath(
|
||||||
@@ -37,30 +73,41 @@ export function audioFilePath(
|
|||||||
unitId: string,
|
unitId: string,
|
||||||
ext: "wav" | "mp3" = "wav",
|
ext: "wav" | "mp3" = "wav",
|
||||||
): string {
|
): string {
|
||||||
return joinPath(audioDirForPaper(cacheKey), `${unitId}.${ext}`);
|
return joinPath(
|
||||||
|
audioDirForPaper(cacheKey),
|
||||||
|
`${sanitizePathSegment(unitId, 80)}.${ext}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lectureSamplePath(name: string): string {
|
export function lectureSamplePath(name: string): string {
|
||||||
return joinPath(lectureDataRoot(), "samples", name);
|
return joinPath(lectureDataRoot(), "samples", sanitizePathSegment(name, 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSampleDir(): Promise<string> {
|
export async function ensureSampleDir(): Promise<string> {
|
||||||
const dir = joinPath(lectureDataRoot(), "samples");
|
const dir = joinPath(lectureDataRoot(), "samples");
|
||||||
await IOUtils.makeDirectory(dir, {
|
await ensureDir(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(path: string): Promise<void> {
|
||||||
|
await IOUtils.makeDirectory(path, {
|
||||||
createAncestors: true,
|
createAncestors: true,
|
||||||
ignoreExisting: true,
|
ignoreExisting: true,
|
||||||
});
|
});
|
||||||
return dir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureLectureDirs(): Promise<void> {
|
export async function ensureLectureDirs(): Promise<void> {
|
||||||
const root = lectureDataRoot();
|
const root = lectureDataRoot();
|
||||||
await IOUtils.makeDirectory(root, {
|
try {
|
||||||
createAncestors: true,
|
await ensureDir(root);
|
||||||
ignoreExisting: true,
|
await ensureDir(joinPath(root, "data"));
|
||||||
});
|
await ensureDir(audioRootDir());
|
||||||
await IOUtils.makeDirectory(audioRootDir(), {
|
} catch (e) {
|
||||||
createAncestors: true,
|
const detail = e instanceof Error ? e.message : String(e);
|
||||||
ignoreExisting: true,
|
throw new Error(
|
||||||
});
|
getString("lecture-storage-write-failed", {
|
||||||
|
args: { path: root, detail },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export function registerLecturePrefsScripts(win: Window) {
|
|||||||
const mineruPathInput = doc.getElementById(
|
const mineruPathInput = doc.getElementById(
|
||||||
"chatpapers-pref-mineru-path",
|
"chatpapers-pref-mineru-path",
|
||||||
) as HTMLInputElement | null;
|
) as HTMLInputElement | null;
|
||||||
|
const lectureDataDirInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-lecture-data-dir",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
const hint = doc.getElementById("chatpapers-pref-tts-hint");
|
const hint = doc.getElementById("chatpapers-pref-tts-hint");
|
||||||
const piperBox = doc.getElementById("chatpapers-pref-piper-box");
|
const piperBox = doc.getElementById("chatpapers-pref-piper-box");
|
||||||
|
|
||||||
@@ -83,5 +86,9 @@ export function registerLecturePrefsScripts(win: Window) {
|
|||||||
setPref("mineruPath", mineruPathInput.value);
|
setPref("mineruPath", mineruPathInput.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lectureDataDirInput?.addEventListener("change", () => {
|
||||||
|
setPref("lectureDataDir", lectureDataDirInput.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
updateHint();
|
updateHint();
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -103,6 +103,7 @@ export type FluentMessageId =
|
|||||||
| 'lecture-status-ready-phase1'
|
| 'lecture-status-ready-phase1'
|
||||||
| 'lecture-status-ready-step1'
|
| 'lecture-status-ready-step1'
|
||||||
| 'lecture-status-ready-step2'
|
| 'lecture-status-ready-step2'
|
||||||
|
| 'lecture-storage-write-failed'
|
||||||
| 'lecture-tts-provider'
|
| 'lecture-tts-provider'
|
||||||
| 'lecture-tts-test'
|
| 'lecture-tts-test'
|
||||||
| 'lecture-tts-test-done'
|
| 'lecture-tts-test-done'
|
||||||
@@ -133,6 +134,8 @@ export type FluentMessageId =
|
|||||||
| 'prefs-hint-empty'
|
| 'prefs-hint-empty'
|
||||||
| 'prefs-hint-local'
|
| 'prefs-hint-local'
|
||||||
| 'prefs-hint-openrouter'
|
| 'prefs-hint-openrouter'
|
||||||
|
| 'prefs-lecture-data-dir'
|
||||||
|
| 'prefs-lecture-data-dir-hint'
|
||||||
| 'prefs-max-context'
|
| 'prefs-max-context'
|
||||||
| 'prefs-max-tokens'
|
| 'prefs-max-tokens'
|
||||||
| 'prefs-mineru-path'
|
| 'prefs-mineru-path'
|
||||||
|
|||||||
Vendored
+1
@@ -29,6 +29,7 @@ declare namespace _ZoteroTypes {
|
|||||||
"mineruPath": string;
|
"mineruPath": string;
|
||||||
"piperPath": string;
|
"piperPath": string;
|
||||||
"piperModelPath": string;
|
"piperModelPath": string;
|
||||||
|
"lectureDataDir": string;
|
||||||
"chatPaneAdaptiveHeight": boolean;
|
"chatPaneAdaptiveHeight": boolean;
|
||||||
"lecturePaneAdaptiveHeight": boolean;
|
"lecturePaneAdaptiveHeight": boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user