Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 3 additions & 12 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -892,13 +892,7 @@ async function importSettings(file) {
// A saved key belongs to the host it was saved for (options.js savedKeyApplies): a key whose platform row, or the
// Obsidian address, points at another origin after an import is dropped, never sent there; the user enters it again.
async function dropMovedKeys(before, after) {
const origin = (url) => {
try {
return new URL(String(url).trim()).origin;
} catch {
return "";
}
};
const origin = MdgSettings.urlOrigin;
const origins = (settings) => new Map((Array.isArray(settings[AI_PROVIDERS]) ? settings[AI_PROVIDERS] : []).map((p) => [String(p?.id || "").trim(), origin(p?.baseUrl)]));
const [was, now] = [origins(before), origins(after)];
const keys = await loadAiProviderKeys();
Expand All @@ -912,9 +906,6 @@ async function dropMovedKeys(before, after) {

const { aiApiKeys: AI_PROVIDER_KEYS_STORAGE, aiProviderList: AI_PROVIDERS } = MdgLimits.KEYS;

// An AI platform's address as requests use it: no spaces, no trailing slashes.
const trimBaseUrl = (url) => String(url || "").trim().replace(/\/+$/, "");

function normalizeAiProvider(item) {
if (!item || typeof item !== "object") return null;
const id = String(item.id || "").trim();
Expand All @@ -923,7 +914,7 @@ function normalizeAiProvider(item) {
id,
presetId: String(item.presetId || "custom"),
name: String(item.name || "").trim() || MdgErrors.LABEL.customAi,
baseUrl: trimBaseUrl(item.baseUrl),
baseUrl: MdgSettings.trimBaseUrl(item.baseUrl),
model: String(item.model || "").trim(),
temperature: typeof item.temperature === "number" ? item.temperature : 0.7,
requiresKey: item.requiresKey !== false,
Expand Down Expand Up @@ -1264,7 +1255,7 @@ async function testAiConnection({ baseUrl, apiKey, model, requiresKey }) {
return { ok: false, error: problem.text, code: problem.code };
}
return probeAiChatCompletion({
baseUrl: trimBaseUrl(baseUrl),
baseUrl: MdgSettings.trimBaseUrl(baseUrl),
apiKey,
model: String(model).trim()
});
Expand Down
21 changes: 4 additions & 17 deletions extension/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ function syncObsidianKeyTag() {
// While a save or a test runs it stays disabled; setBusy(null) calls this again.
// The result next to it is for the 地址 and Key it tested: an edit away from them clears it.
function syncObsidianTest() {
const typed = { obsidianApiBaseUrl: normalizeBaseUrl(elements.obsidianApiBaseUrl.value), obsidianApiKey: MdgSettings.normalizeApiKey(elements.obsidianApiKey.value) };
const typed = { obsidianApiBaseUrl: MdgSettings.trimBaseUrl(elements.obsidianApiBaseUrl.value), obsidianApiKey: MdgSettings.normalizeApiKey(elements.obsidianApiKey.value) };
if (obsidianTest && obsidianTest.target !== obsidianTarget(typed)) {
obsidianTests.next();
setTestResult("");
Expand Down Expand Up @@ -552,7 +552,7 @@ function readFormPayload() {
const normalized = MdgSettings.normalizeSyncSettings(form);
return {
...Object.fromEntries(Object.keys(form).map((k) => [k, normalized[k]])),
obsidianApiBaseUrl: normalizeBaseUrl(elements.obsidianApiBaseUrl.value),
obsidianApiBaseUrl: MdgSettings.trimBaseUrl(elements.obsidianApiBaseUrl.value),
obsidianApiKey: MdgSettings.normalizeApiKey(elements.obsidianApiKey.value)
};
}
Expand Down Expand Up @@ -988,11 +988,6 @@ function closeAllFixedPropertyMenus() {
});
}

function normalizeBaseUrl(value) {
return String(value || "").trim().replace(/\/+$/g, "");
}


async function testConnection() {
clearInputErrors();
const payload = collectFormPayload();
Expand Down Expand Up @@ -1118,7 +1113,7 @@ function addAiProviderRow(item = {}) {
row.className = "ai-provider-row";
row.dataset.providerId = id;
row.dataset.hasSavedKey = hasSavedKey ? "1" : "0";
row.dataset.keyOrigin = urlOrigin(baseUrl);
row.dataset.keyOrigin = MdgSettings.urlOrigin(baseUrl);
row.dataset.currentPresetId = presetId;
row.innerHTML = `
<select class="ai-provider-preset" name="aiProviderPreset" aria-label="平台">
Expand Down Expand Up @@ -1288,15 +1283,7 @@ async function testAiProviderRow(row, sync) {
// A saved Key belongs to the host it was saved for: once 接口地址 points at another host it is neither sent nor counted as
// filled, so switching platform asks for that platform's Key.
function savedKeyApplies(row) {
return row.dataset.hasSavedKey === "1" && urlOrigin(row.querySelector(".ai-provider-baseurl").value) === row.dataset.keyOrigin;
}

function urlOrigin(url) {
try {
return new URL(String(url).trim()).origin;
} catch {
return "";
}
return row.dataset.hasSavedKey === "1" && MdgSettings.urlOrigin(row.querySelector(".ai-provider-baseurl").value) === row.dataset.keyOrigin;
}

function apiKeyPlaceholder(hasSavedKey, requiresKey) {
Expand Down
20 changes: 10 additions & 10 deletions extension/options.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ vm.runInContext(["const { escapeHtml } = MdgErrors;", fn("buildFixedPropertyValu
for (const bad of XSS) assertInert(ctx.buildFixedPropertyValueControl("text", bad), "property value");

// Obsidian's 测试连接 is disabled with the reason while the 地址 or the Key is empty, like an AI platform row's (H2).
vm.runInContext([fn("syncObsidianTest"), fn("obsidianTarget"), fn("normalizeBaseUrl"), fn("setTestResult")].join("\n"), ctx);
vm.runInContext([fn("syncObsidianTest"), fn("obsidianTarget"), fn("setTestResult")].join("\n"), ctx);
const testBtn = { setAttribute() {}, removeAttribute() {} };
Object.assign(ctx.elements, { testConnectionBtn: testBtn, obsidianApiBaseUrl: { value: "" }, obsidianApiKey: { value: " " }, obsidianTestResult: { textContent: "", dataset: {} } });
ctx.syncObsidianTest();
Expand All @@ -110,7 +110,7 @@ ctx.obsidianTest = null;
(async () => {
// fn() slices from "function": testConnection is async.
vm.runInContext([`async ${fn("testConnection")}`, "let hasUnsavedChanges = false;"].join("\n"), ctx);
const typed = () => ({ obsidianApiBaseUrl: ctx.normalizeBaseUrl(ctx.elements.obsidianApiBaseUrl.value), obsidianApiKey: ctx.MdgSettings.normalizeApiKey(ctx.elements.obsidianApiKey.value) });
const typed = () => ({ obsidianApiBaseUrl: ctx.MdgSettings.trimBaseUrl(ctx.elements.obsidianApiBaseUrl.value), obsidianApiKey: ctx.MdgSettings.normalizeApiKey(ctx.elements.obsidianApiKey.value) });
Object.assign(ctx, { clearInputErrors() {}, collectFormPayload: typed, validateSettings: () => ({ ok: true }), applyValidationError() {}, setBusy() {}, renderSavedState() {} });
let answer;
ctx.chrome = { runtime: { sendMessage: () => new Promise((r) => (answer = () => r({ ok: true, service: "Obsidian" }))) } };
Expand Down Expand Up @@ -172,8 +172,8 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh
};
const parts = {};
const row = { ...stub(), querySelector: (sel) => (parts[sel] ||= stub()), querySelectorAll: () => [] };
const page = vm.createContext({ URL, MdgErrors: ctx.MdgErrors, escapeHtml: ctx.MdgErrors.escapeHtml, AI_PRESETS: [{ id: "custom", name: "自定义", baseUrl: "", model: "" }], document: { createElement: () => row }, elements: { aiProvidersList: { appendChild() {} } }, updateAiProvidersEmptyState() {} });
vm.runInContext([constant("TRASH_ICON"), constant("AI_FIELD"), fn("addAiProviderRow"), fn("presetOf"), constant("PLAIN_HTTP_WARNING"), fn("plainHttpWarning"), fn("apiKeyPlaceholder"), fn("generateAiProviderId"), fn("savedKeyApplies"), fn("urlOrigin")].join("\n"), page);
const page = vm.createContext({ URL, MdgErrors: ctx.MdgErrors, MdgSettings: ctx.MdgSettings, escapeHtml: ctx.MdgErrors.escapeHtml, AI_PRESETS: [{ id: "custom", name: "自定义", baseUrl: "", model: "" }], document: { createElement: () => row }, elements: { aiProvidersList: { appendChild() {} } }, updateAiProvidersEmptyState() {} });
vm.runInContext([constant("TRASH_ICON"), constant("AI_FIELD"), fn("addAiProviderRow"), fn("presetOf"), constant("PLAIN_HTTP_WARNING"), fn("plainHttpWarning"), fn("apiKeyPlaceholder"), fn("generateAiProviderId"), fn("savedKeyApplies")].join("\n"), page);
page.addAiProviderRow({});
const test = parts[".ai-provider-test"];
const type = (sel, v) => ((parts[sel].value = v), parts[sel].L.input.forEach((f) => f()));
Expand All @@ -194,7 +194,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh
{
const btn = (textContent) => ({ textContent, dataset: {}, setAttribute() {}, removeAttribute() {} });
const page = vm.createContext({ MdgErrors: ctx.MdgErrors, MdgSettings: ctx.MdgSettings, busyButton: null, obsidianTest: null, elements: { saveBtn: btn("保存"), testConnectionBtn: btn("测试连接"), obsidianApiBaseUrl: { value: "http://127.0.0.1:27123" }, obsidianApiKey: { value: "" } } });
vm.runInContext([fn("setBusy"), fn("syncObsidianTest"), fn("normalizeBaseUrl")].join("\n"), page);
vm.runInContext([fn("setBusy"), fn("syncObsidianTest")].join("\n"), page);
page.setBusy(page.elements.testConnectionBtn);
page.elements.obsidianApiKey.value = "new key";
page.syncObsidianTest();
Expand Down Expand Up @@ -247,7 +247,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh
updateAiProvidersEmptyState() {},
chrome: { permissions: { request: async () => true }, runtime: { sendMessage: (m) => (sent.push(m), new Promise((r) => (answer = r))) } }
});
vm.runInContext([constant("TRASH_ICON"), constant("AI_FIELD"), fn("addAiProviderRow"), fn("presetOf"), constant("PLAIN_HTTP_WARNING"), fn("plainHttpWarning"), fn("apiKeyPlaceholder"), fn("generateAiProviderId"), fn("savedKeyApplies"), fn("urlOrigin"), fn("collectAiProviders"), fn("hostPermissionPattern"), fn("showAiProviderStatus"), `async ${fn("testAiProviderRow")}`].join("\n"), page);
vm.runInContext([constant("TRASH_ICON"), constant("AI_FIELD"), fn("addAiProviderRow"), fn("presetOf"), constant("PLAIN_HTTP_WARNING"), fn("plainHttpWarning"), fn("apiKeyPlaceholder"), fn("generateAiProviderId"), fn("savedKeyApplies"), fn("collectAiProviders"), fn("hostPermissionPattern"), fn("showAiProviderStatus"), `async ${fn("testAiProviderRow")}`].join("\n"), page);
page.addAiProviderRow({ id: "p1", presetId: "deepseek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-flash", hasSavedKey: true });
parts[".ai-provider-preset"].value = "deepseek";
const type = (sel, v) => ((parts[sel].value = v), parts[sel].L.input.forEach((f) => f()));
Expand Down Expand Up @@ -403,11 +403,11 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh
let reply;
const row = { ...stub(), querySelector: (sel) => (parts[sel] ||= stub()), querySelectorAll: () => [], remove: () => removed++ };
const page = vm.createContext({
URL, MdgErrors: ctx.MdgErrors, escapeHtml: ctx.MdgErrors.escapeHtml, AI_PRESETS: [{ id: "custom", name: "自定义", baseUrl: "", model: "", requiresKey: true }],
URL, MdgErrors: ctx.MdgErrors, MdgSettings: ctx.MdgSettings, escapeHtml: ctx.MdgErrors.escapeHtml, AI_PRESETS: [{ id: "custom", name: "自定义", baseUrl: "", model: "", requiresKey: true }],
document: { createElement: () => row }, elements: { aiProvidersList: { appendChild() {} } },
updateAiProvidersEmptyState() {}, updateSavedProviders() {}, chrome: { runtime: { sendMessage: async () => reply() } }
});
vm.runInContext([constant("TRASH_ICON"), constant("AI_FIELD"), fn("addAiProviderRow"), fn("presetOf"), constant("PLAIN_HTTP_WARNING"), fn("plainHttpWarning"), fn("apiKeyPlaceholder"), fn("generateAiProviderId"), fn("savedKeyApplies"), fn("urlOrigin"), fn("showAiProviderStatus")].join("\n"), page);
vm.runInContext([constant("TRASH_ICON"), constant("AI_FIELD"), fn("addAiProviderRow"), fn("presetOf"), constant("PLAIN_HTTP_WARNING"), fn("plainHttpWarning"), fn("apiKeyPlaceholder"), fn("generateAiProviderId"), fn("savedKeyApplies"), fn("showAiProviderStatus")].join("\n"), page);
page.addAiProviderRow({ id: "p1" });
const del = parts[".ai-provider-remove"];
const status = parts[".ai-provider-status"];
Expand Down Expand Up @@ -443,7 +443,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh
chrome: { permissions: { request: async ({ origins }) => (log.push(`ask ${origins}`), allow) }, runtime: { sendMessage: async (m) => (log.push(`test ${m.baseUrl}`), { ok: true }) } }
});
vm.runInContext(fs.readFileSync(path.join(__dirname, "errors.js"), "utf8"), page);
vm.runInContext([fn("hostPermissionPattern"), fn("showAiProviderStatus"), fn("savedKeyApplies"), fn("urlOrigin"), fn("presetOf"), `async ${fn("testAiProviderRow")}`].join("\n"), page);
vm.runInContext([fn("hostPermissionPattern"), fn("showAiProviderStatus"), fn("savedKeyApplies"), fn("presetOf"), `async ${fn("testAiProviderRow")}`].join("\n"), page);
// L4: the declined permission carries its code, as the background's does.
const replies = [];
const realText = page.MdgErrors.text;
Expand Down Expand Up @@ -484,7 +484,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh
renderBiliMarks() {}, renderStorageLimits() {}, syncObsidianBody() {}, syncSeenRows() {}, syncBiliMarks() {}, renderAiProviders() {},
renderHostPermissionBanner() {}, hostPermissionUrls() {}, markSaved() {}
});
vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload"), fn("normalizeBaseUrl")].join("\n"), page);
vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload")].join("\n"), page);
await page.loadSettings();
const payload = page.readFormPayload();
const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"];
Expand Down
12 changes: 12 additions & 0 deletions extension/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@
// A note field as stored: "bvid" was video_id's name before the site registry (the settings page and note.js both read it).
const normalizeFrontmatterField = (value) => text(value).replace(/^bvid$/, "video_id");
const normalizeApiKey = (value) => text(value).replace(/^Bearer\s+/i, "").trim();
// An address (AI platform, Obsidian) as requests use it: no spaces, no trailing slashes.
const trimBaseUrl = (url) => String(url || "").trim().replace(/\/+$/, "");
// A saved Key belongs to its address's origin (options.js savedKeyApplies, background.js dropMovedKeys); "" if not a URL.
const urlOrigin = (url) => {
try {
return new URL(String(url).trim()).origin;
} catch {
return "";
}
};
const normalizeDownloadFormat = (value) => (value === "txt" ? "txt" : "srt");
const normalizePlayerAiQuickPrompt = (value) => text(value);
// 快捷追问: trimmed, no blanks or repeats, at most MAX_PRESET_PROMPTS.
Expand Down Expand Up @@ -246,6 +256,8 @@
normalizeAiSystemPrompt,
normalizeSeenThreshold,
normalizeApiKey,
trimBaseUrl,
urlOrigin,
normalizeFrontmatterField,
normalizeDownloadFormat,
normalizePlayerAiQuickPrompt,
Expand Down
5 changes: 4 additions & 1 deletion extension/settings.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const vm = require("vm");
const assert = require("assert");

const read = (file) => fs.readFileSync(path.join(__dirname, file), "utf8");
const ctx = vm.createContext({});
const ctx = vm.createContext({ URL });
ctx.globalThis = ctx;
vm.runInContext(read("common.js"), ctx);
vm.runInContext(read("settings.js"), ctx);
Expand Down Expand Up @@ -34,6 +34,9 @@ assert.deepStrictEqual(["zh-Hans", "fr", undefined].map(S.normalizeSubtitleLang)

assert.strictEqual(S.normalizeApiKey(" Bearer abc "), "abc");
assert.strictEqual(S.normalizeApiKey(undefined), "");
// An AI platform's or Obsidian's address, and the origin its saved Key belongs to.
assert.deepStrictEqual([" https://api.x.com/v1// ", "", undefined].map(S.trimBaseUrl), ["https://api.x.com/v1", "", ""]);
assert.deepStrictEqual([" https://api.x.com/v1/ ", "http://127.0.0.1:27123", "api.x.com", "", undefined].map(S.urlOrigin), ["https://api.x.com", "http://127.0.0.1:27123", "", "", ""]);
assert.strictEqual(S.normalizeDownloadFormat("txt"), "txt");
assert.strictEqual(S.normalizeDownloadFormat("TXT"), "srt");
// Non-strings keep their text (an imported file may carry numbers); only null and undefined are empty.
Expand Down
Loading