From 57ca0e101eee46dbabb17a2d30753803c71c5558 Mon Sep 17 00:00:00 2001 From: sean2077 <37324769+sean2077@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:19 +0800 Subject: [PATCH] fix: preserve settings integrity and compatibility Migrate custom query credentials to SecretStorage and apply custom parameters at request time. Preserve configuration-order scheduling, transactional reorder rollback, and older Obsidian fallback metadata. Align the production bundle with the official ES2021 target while retaining the frozen size gates. --- AGENTS.md | 1 + README.md | 1 + README.zh.md | 1 + docs/development.md | 8 +- esbuild.config.mjs | 3 +- src/core/credential-storage.ts | 25 +++- src/core/time-rule-scheduler.ts | 10 +- src/i18n/en.ts | 11 +- src/i18n/zh-cn.ts | 9 +- src/modals/wallpaper-api-modal.ts | 157 ++++++++++++++++++------- src/settings/sections/api-settings.ts | 6 +- src/settings/sections/bg-management.ts | 4 +- src/settings/sections/mode-settings.ts | 6 +- src/utils/drag-sort.ts | 8 +- src/wallpaper-apis/core/index.ts | 1 + src/wallpaper-apis/core/url-params.ts | 13 ++ src/wallpaper-apis/providers/custom.ts | 3 +- tests/credential-storage.test.ts | 47 ++++++++ tests/drag-sort.test.ts | 25 +++- tests/release-contract.test.ts | 6 + tests/time-rule-scheduler.test.ts | 12 +- tests/wallpaper-api-url.test.ts | 23 ++++ versions.json | 3 + 23 files changed, 310 insertions(+), 73 deletions(-) create mode 100644 src/wallpaper-apis/core/url-params.ts create mode 100644 tests/wallpaper-api-url.test.ts create mode 100644 versions.json diff --git a/AGENTS.md b/AGENTS.md index ea21718..86fbfb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Dynamic Theme Background is a TypeScript/CSS Obsidian community plugin for deskt - Keep setting types, defaults, UI, persistence compatibility, and both source locale files aligned when configuration changes. - Keep provider credentials and custom-header values in Obsidian SecretStorage: persist only references, migrate legacy plaintext all-or-retain, and hydrate isolated runtime config clones. - Let semantic-release own normal version synchronization across `manifest.json`, `package.json`, `package-lock.json`, and `src/version.ts`; do not manually bump them outside a release task. +- When `minAppVersion` increases, preserve the last compatible release in `versions.json` so older Obsidian clients can fall back safely. - Run `npm run check` for the complete automated gate; use `npm test`, `npm run build`, `npm run lint`, or `npm run lint:css` for focused iteration. Record the relevant manual Obsidian checks for behavior changes that require a real app, theme, or device. diff --git a/README.md b/README.md index 3bedfd8..6540fe3 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ - Local images, colors, and gradients work without provider requests. Enabling a wallpaper provider, using a custom JSON endpoint, or applying/saving a remote image can contact the configured third party; that service's terms and privacy policy apply. - Provider keys, tokens, and every custom-header value are stored through Obsidian SecretStorage. The plugin's `data.json` contains only non-secret settings and SecretStorage IDs, not those credential values. +- Custom API query credentials must use **Secret query parameters**. The raw **Extra parameters** JSON accepts only non-secret string, number, and boolean values; those parameters are appended to the custom endpoint at request time. - On the first load after upgrading, legacy plaintext credentials are copied to SecretStorage before `data.json` is replaced. If any secret write fails, the old `data.json` is left unchanged and plugin startup stops so credentials are not lost or partially migrated. - Secrets can be shared across plugins or configurations, so deleting a DTB API removes only its reference and does not delete the Obsidian-owned secret. - The plugin has no telemetry or analytics. Diagnostic logging sanitizes credential-bearing URLs and headers, bounds nested data, and does not intentionally log API keys or tokens. diff --git a/README.zh.md b/README.zh.md index fb47dbb..cb39f11 100644 --- a/README.zh.md +++ b/README.zh.md @@ -79,6 +79,7 @@ - 本地图片、纯色和渐变无需请求壁纸服务。启用壁纸提供商、使用自定义 JSON 接口或应用/保存远程图片时,可能访问所配置的第三方;相应服务的条款与隐私政策适用。 - 提供商密钥、令牌和所有自定义请求头值通过 Obsidian SecretStorage 保存。插件的 `data.json` 只包含非敏感设置和 SecretStorage ID,不包含这些凭据值。 +- 自定义 API 的查询凭据必须使用**密钥查询参数**。原始**额外参数** JSON 只接受非敏感的字符串、数字和布尔值;请求时会把这些参数追加到自定义端点。 - 升级后首次加载时,旧版明文凭据会先完整写入 SecretStorage,再替换 `data.json`。若任一密钥写入失败,旧 `data.json` 会保持不变并停止插件启动,避免凭据丢失或半迁移。 - 密钥可能由多个插件或配置共享,因此删除 DTB API 只会移除引用,不会删除由 Obsidian 管理的密钥。 - 插件不包含遥测或分析。诊断日志会清理带凭据的 URL 与请求头、限制嵌套数据规模,并且不会有意记录 API key 或 token。 diff --git a/docs/development.md b/docs/development.md index 4531061..394ab80 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,6 +18,8 @@ This guide holds lower-frequency implementation detail referenced by the root `A Install the locked dependency set with `npm ci`. The project pins approval for esbuild's reviewed install script; after dependency upgrades, run `npm install-scripts ls` and review any newly reported script before approving it. +The bundle follows Obsidian's official `es2021` sample target and emits UTF-8 so +localized strings do not expand into ASCII escape sequences. | Task | Command | Notes | |---|---|---| @@ -45,7 +47,7 @@ Automated tests cover pure policies and structural lifecycle/release contracts. Persisted data passes through `src/core/settings.ts`, which clones defaults and validates scalar and nested values before runtime use. When the schema changes, update the type, default, normalizer, relevant settings UI, tests, and `src/i18n/en.ts` plus `src/i18n/zh-cn.ts`. -Provider password fields and every user-defined header value are persisted as SecretStorage IDs under `secretRefs`. `src/core/credential-storage.ts` owns lossless legacy migration, the plaintext save guard, and short-lived runtime hydration; `src/plugin.ts` is the only composition boundary that supplies `app.secretStorage`. Never pass a persisted provider config directly to `WallpaperApiManager` or write hydrated values back to plugin settings. +Provider password fields, credential-like custom query parameters, and every user-defined header value are persisted as SecretStorage IDs under `secretRefs`. `src/core/credential-storage.ts` owns lossless legacy migration, the plaintext save guard, and short-lived runtime hydration; `src/plugin.ts` is the only composition boundary that supplies `app.secretStorage`. Never pass a persisted provider config directly to `WallpaperApiManager` or write hydrated values back to plugin settings. Obsidian 1.13+ renders `DTBSettingTab.getSettingDefinitions()` as declarative pages. Keep `display()` and the alternate settings view as the Obsidian 1.11.4–1.12 compatibility path; new 1.13-only calls must stay behind `requireApiVersion` guards in `src/core/obsidian-compat.ts`. @@ -57,7 +59,7 @@ Create or update the command module under `src/commands/`, then keep the central A provider change can span the provider type/config contract, implementation and registry call, barrel export, settings descriptors, and localized labels. Route requests through the shared `BaseWallpaperApi.requestJson` policy so protocol, timeout, response-size, result-count, and credential-redaction rules remain consistent. Treat its result as `unknown` and parse it through the provider-specific validators in `src/wallpaper-apis/core/provider-responses.ts` before using a typed model. Keep enable/disable state notifications and failure rollback behavior consistent with `WallpaperApiManager`. -Custom providers use the no-evaluation JSONPath subset in `src/core/safe-json-path.ts`: properties, quoted keys, indexes, positive-step slices, unions, wildcards, and recursive descent. Filters and script expressions are intentionally rejected. +Custom providers append validated primitive `params` to the endpoint query. Query credentials must enter through `secretRefs.params` so only the hydrated runtime clone contains their values. Custom providers use the no-evaluation JSONPath subset in `src/core/safe-json-path.ts`: properties, quoted keys, indexes, positive-step slices, unions, wildcards, and recursive descent. Filters and script expressions are intentionally rejected. ### Runtime styling @@ -69,4 +71,4 @@ Keep `README.md` and `README.zh.md` aligned for user-facing changes. Keep the tw ## Generated and release-owned files -`main.js` is generated, ignored, and attached to GitHub releases with `manifest.json` and `styles.css`; do not hand-edit or commit it. Pushes to `main` or `master` install with `npm ci`, run `npm run check`, then run semantic-release, which derives the next version from Conventional Commits, updates `CHANGELOG.md`, `manifest.json`, `package.json`, `package-lock.json`, and `src/version.ts`, creates the release commit, and publishes the release assets. Ordinary feature and fix work should not pre-bump those version files. +`main.js` is generated, ignored, and attached to GitHub releases with `manifest.json` and `styles.css`; do not hand-edit or commit it. `versions.json` preserves the most recent compatible plugin release whenever `minAppVersion` increases. Pushes to `main` or `master` install with `npm ci`, run `npm run check`, then run semantic-release, which derives the next version from Conventional Commits, updates `CHANGELOG.md`, `manifest.json`, `package.json`, `package-lock.json`, and `src/version.ts`, creates the release commit, and publishes the release assets. Ordinary feature and fix work should not pre-bump those version files. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 5b95b9d..e9c4ea1 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -17,6 +17,7 @@ const context = await esbuild.context({ }, entryPoints: ["src/main.ts"], bundle: true, + charset: "utf8", external: [ "obsidian", "electron", @@ -33,7 +34,7 @@ const context = await esbuild.context({ "@lezer/lr", ...builtinModules], format: "cjs", - target: "es2018", + target: "es2021", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, diff --git a/src/core/credential-storage.ts b/src/core/credential-storage.ts index 6062fa1..92e195a 100644 --- a/src/core/credential-storage.ts +++ b/src/core/credential-storage.ts @@ -9,6 +9,8 @@ export const CURRENT_CREDENTIAL_STORAGE_VERSION = 1; const SECRET_ID_PREFIX = "dynamic-theme-background"; const SECRET_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const CREDENTIAL_PARAM_PATTERN = + /(?:^|[-_.])(?:api[-_]?(?:key|token)|access[-_]?(?:key|token)|client[-_]?(?:id|secret)|refresh[-_]?token|authorization|auth(?:[-_]?token)?|bearer(?:[-_]?token)?|password|passphrase|secret|token|key)(?:$|[-_.])/iu; export interface SecretStore { getSecret(id: string): string | null; @@ -17,6 +19,11 @@ export interface SecretStore { export type SensitiveParamResolver = (config: WallpaperApiConfig) => readonly string[]; +/** Recognizes conventional query-credential names that must never reach data.json. */ +export function isCredentialParameterKey(key: string): boolean { + return CREDENTIAL_PARAM_PATTERN.test(key.trim()); +} + export interface CredentialMigrationResult { settings: DTBSettings; migrated: boolean; @@ -122,6 +129,18 @@ function compactSecretRefs(secretRefs: WallpaperApiSecretRefs): WallpaperApiSecr return params || headers ? { params, headers } : undefined; } +function resolveCredentialFields( + config: WallpaperApiConfig, + resolveSensitiveParams: SensitiveParamResolver +): string[] { + return Array.from( + new Set([ + ...resolveSensitiveParams(config), + ...Object.keys(config.params).filter(isCredentialParameterKey), + ]) + ); +} + /** * Copies legacy credentials into SecretStorage and returns a sanitized settings clone. * The caller should persist the returned clone only after this function succeeds. @@ -141,7 +160,7 @@ export function migrateSettingsCredentials( refs.params ??= {}; refs.headers ??= {}; - for (const field of resolveSensitiveParams(config)) { + for (const field of resolveCredentialFields(config, resolveSensitiveParams)) { if (!(field in config.params)) continue; const rawValue = config.params[field]; delete config.params[field]; @@ -226,7 +245,9 @@ export function assertSettingsCredentialsAreReferences( resolveSensitiveParams: SensitiveParamResolver ): void { for (const config of settings.wallpaperApis) { - const hasSensitiveParam = resolveSensitiveParams(config).some((field) => field in config.params); + const hasSensitiveParam = resolveCredentialFields(config, resolveSensitiveParams).some( + (field) => field in config.params + ); const hasHeaders = Object.keys(config.headers ?? {}).length > 0; if (hasSensitiveParam || hasHeaders) { throw new Error("Refusing to persist plaintext credentials in wallpaper API settings"); diff --git a/src/core/time-rule-scheduler.ts b/src/core/time-rule-scheduler.ts index 4cad977..5c86247 100644 --- a/src/core/time-rule-scheduler.ts +++ b/src/core/time-rule-scheduler.ts @@ -6,7 +6,6 @@ import type { TimeRule } from "../types"; interface CompiledRule { endTime: number; - order: number; rule: TimeRule; startTime: number; } @@ -22,15 +21,12 @@ export class TimeRuleScheduler { updateRules(rules: TimeRule[]): void { this.rules = rules - .map((rule, order) => { + .map((rule) => { if (!rule.enabled) return null; const parsed = this.parseTimeRule(rule); - return parsed && parsed.startTime !== parsed.endTime - ? { ...parsed, order, rule } - : null; + return parsed && parsed.startTime !== parsed.endTime ? { ...parsed, rule } : null; }) - .filter((rule): rule is CompiledRule => rule !== null) - .sort((left, right) => left.startTime - right.startTime || left.order - right.order); + .filter((rule): rule is CompiledRule => rule !== null); } /** diff --git a/src/i18n/en.ts b/src/i18n/en.ts index cb0ce4c..b4872f4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -211,6 +211,10 @@ export default { // ===== API 模态窗口 ===== api_modal_invalid_json: "Invalid JSON in extra parameters", + api_modal_invalid_extra_param: 'Extra parameter "{key}" must be a string, number, or boolean.', + api_modal_duplicate_param: 'Parameter "{key}" is configured more than once.', + api_modal_sensitive_extra_param: + 'Move credential parameter "{key}" to Secret query parameters so it is not stored in data.json.', api_modal_enter_api_name: "Please enter an API name", api_modal_invalid_params: "Invalid API parameters: {errors}", api_modal_testing_config: "Testing API configuration...", @@ -224,13 +228,18 @@ export default { api_modal_add_header: "Add header", api_modal_header_key: "Header key", api_modal_header_secret: "Obsidian secret", - api_modal_secret_missing: "Choose an Obsidian secret for every required credential and header.", + api_modal_secret_missing: "Choose an Obsidian secret for every required credential, query parameter, and header.", api_modal_api_parameters: "API parameters", api_modal_api_documentation: "📖 API documentation", api_modal_token_url: "🔑 Token URL", api_modal_extra_params: "Extra parameters (JSON)", api_modal_extra_params_desc: "Additional parameters not covered above, in JSON format", api_modal_extra_params_placeholder: '{\n "customParam": "value",\n "anotherParam": 123\n}', + api_modal_secret_params_optional: "Secret query parameters (optional)", + api_modal_secret_params_desc: + "Choose SecretStorage entries for query credentials. Values are added only to runtime requests.", + api_modal_add_secret_param: "Add secret parameter", + api_modal_secret_param_key: "Parameter name", api_modal_test_api: "Test API", api_modal_save: "Save", api_modal_title_edit: "Edit wallpaper API", diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index 8b6cc79..72c1633 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -194,6 +194,9 @@ export default { // ===== API 模态窗口 ===== api_modal_invalid_json: "额外参数中的 JSON 格式无效", + api_modal_invalid_extra_param: "额外参数“{key}”必须是字符串、数字或布尔值。", + api_modal_duplicate_param: "参数“{key}”被重复配置。", + api_modal_sensitive_extra_param: "请将凭据参数“{key}”移至密钥查询参数,避免写入 data.json。", api_modal_enter_api_name: "请输入 API 名称", api_modal_invalid_params: "无效的 API 参数:{errors}", api_modal_testing_config: "正在测试 API 配置...", @@ -207,13 +210,17 @@ export default { api_modal_add_header: "添加请求头", api_modal_header_key: "请求头键", api_modal_header_secret: "Obsidian 密钥", - api_modal_secret_missing: "请为每个必需凭据和请求头选择一个 Obsidian 密钥。", + api_modal_secret_missing: "请为每个必需凭据、查询参数和请求头选择一个 Obsidian 密钥。", api_modal_api_parameters: "API 参数", api_modal_api_documentation: "📖 API 文档", api_modal_token_url: "🔑 Token 获取地址", api_modal_extra_params: "额外参数(JSON)", api_modal_extra_params_desc: "上述未涵盖的额外参数,JSON 格式", api_modal_extra_params_placeholder: '{\n "customParam": "value",\n "anotherParam": 123\n}', + api_modal_secret_params_optional: "密钥查询参数(可选)", + api_modal_secret_params_desc: "为查询凭据选择 SecretStorage 条目;其值只在运行时请求中加入。", + api_modal_add_secret_param: "添加密钥参数", + api_modal_secret_param_key: "参数名称", api_modal_test_api: "测试 API", api_modal_save: "保存", api_modal_title_edit: "编辑壁纸 API", diff --git a/src/modals/wallpaper-api-modal.ts b/src/modals/wallpaper-api-modal.ts index d935e23..7147a3d 100644 --- a/src/modals/wallpaper-api-modal.ts +++ b/src/modals/wallpaper-api-modal.ts @@ -1,4 +1,5 @@ import { Modal, Notice, SecretComponent } from "obsidian"; +import { isCredentialParameterKey } from "../core/credential-storage"; import { logger } from "../core/logger"; import type DynamicThemeBackgroundPlugin from "../plugin"; import { generateId } from "../utils/utils"; @@ -15,6 +16,12 @@ import { WallpaperApiType, } from "../wallpaper-apis"; +function isApiValue(value: unknown): value is ApiValueType { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +type SecretInput = { key: HTMLInputElement; secretId: string }; + /** * 壁纸 API 编辑模态框类,用于在 Obsidian 插件中创建和编辑壁纸 API 配置。 */ @@ -30,13 +37,15 @@ export class WallpaperApiEditorModal extends Modal { urlInput!: HTMLInputElement; // Headers配置 headersContainer!: HTMLDivElement; - headerInputs: Array<{ key: HTMLInputElement; secretId: string }> = []; + headerInputs: SecretInput[] = []; // 参数配置容器的引用 paramsSectionContainer!: HTMLElement; // 动态参数输入元素映射 paramInputs: Map = new Map(); secretParamRefs: Map = new Map(); + secretParamsContainer!: HTMLDivElement; + extraSecretParamInputs: SecretInput[] = []; // 额外参数输入 extraParamsTextarea!: HTMLTextAreaElement; @@ -149,7 +158,7 @@ export class WallpaperApiEditorModal extends Modal { // 渲染已有的 SecretStorage 引用 if (this.apiConfig.secretRefs?.headers) { Object.entries(this.apiConfig.secretRefs.headers).forEach(([key, secretId]) => { - this.addHeaderInput(key, secretId); + this.addSecretInput("header", key, secretId); }); } @@ -159,41 +168,7 @@ export class WallpaperApiEditorModal extends Modal { type: "button", cls: "dtb-button", }); - addHeaderBtn.onclick = () => this.addHeaderInput(); - } - - // 添加header输入行 - private addHeaderInput(key = "", secretId = "") { - const headerRow = this.headersContainer.createDiv("dtb-list-row"); - - const keyInput = headerRow.createEl("input", { - type: "text", - value: key, - placeholder: t("api_modal_header_key"), - cls: "dtb-input", - }); - - const secretContainer = headerRow.createDiv("dtb-secret-control"); - secretContainer.setAttribute("aria-label", t("api_modal_header_secret")); - const headerInput = { key: keyInput, secretId }; - new SecretComponent(this.app, secretContainer).setValue(secretId).onChange((value) => { - headerInput.secretId = value; - }); - - const removeBtn = headerRow.createEl("button", { - text: "×", - type: "button", - cls: "dtb-remove-button", - }); - removeBtn.onclick = () => { - headerRow.remove(); - const index = this.headerInputs.findIndex((h) => h.key === keyInput); - if (index > -1) { - this.headerInputs.splice(index, 1); - } - }; - - this.headerInputs.push(headerInput); + addHeaderBtn.onclick = () => this.addSecretInput("header"); } // 创建参数配置部分 @@ -207,6 +182,7 @@ export class WallpaperApiEditorModal extends Modal { // 清除现有的参数输入 this.paramInputs.clear(); this.secretParamRefs.clear(); + this.extraSecretParamInputs = []; // 使用保存的容器引用而不是DOM查询 if (!this.paramsSectionContainer) return; @@ -218,7 +194,7 @@ export class WallpaperApiEditorModal extends Modal { this.createParamsSectionHeader(); // 创建参数输入 - const selectedType = this.typeSelect?.value || this.apiConfig.type; + const selectedType = (this.typeSelect?.value as WallpaperApiType) || this.apiConfig.type; const paramDescriptors = this.getParamDescriptorsForType(selectedType); if (paramDescriptors.length > 0) { @@ -227,6 +203,9 @@ export class WallpaperApiEditorModal extends Modal { // 额外参数JSON输入 this.createExtraParamsInput(this.paramsSectionContainer); + if (selectedType === WallpaperApiType.Custom) { + this.createSecretParamsSection(this.paramsSectionContainer, paramDescriptors); + } } // 创建参数部分的标题和文档链接 @@ -342,6 +321,64 @@ export class WallpaperApiEditorModal extends Modal { }); } + private createSecretParamsSection( + container: HTMLElement, + descriptors: WallpaperApiParamDescriptor[] + ): void { + container.createEl("label", { text: t("api_modal_secret_params_optional") }); + container.createEl("small", { + text: t("api_modal_secret_params_desc"), + cls: "dtb-field-description", + }); + this.secretParamsContainer = container.createDiv("dtb-list-container"); + + const descriptorSecretKeys = new Set( + descriptors.filter((descriptor) => descriptor.type === "password").map((descriptor) => descriptor.key) + ); + if ((this.typeSelect.value as WallpaperApiType) === this.apiConfig.type) { + for (const [key, secretId] of Object.entries(this.apiConfig.secretRefs?.params ?? {})) { + if (!descriptorSecretKeys.has(key)) this.addSecretInput("param", key, secretId); + } + } + + const addButton = container.createEl("button", { + text: t("api_modal_add_secret_param"), + type: "button", + cls: "dtb-button", + }); + addButton.onclick = () => this.addSecretInput("param"); + } + + private addSecretInput(kind: "header" | "param", key = "", secretId = ""): void { + const inputs = kind === "header" ? this.headerInputs : this.extraSecretParamInputs; + const container = kind === "header" ? this.headersContainer : this.secretParamsContainer; + const row = container.createDiv("dtb-list-row"); + const keyInput = row.createEl("input", { + type: "text", + value: key, + placeholder: t(kind === "header" ? "api_modal_header_key" : "api_modal_secret_param_key"), + cls: "dtb-input", + }); + const secretContainer = row.createDiv("dtb-secret-control"); + secretContainer.setAttribute("aria-label", t("api_modal_header_secret")); + const input = { key: keyInput, secretId }; + new SecretComponent(this.app, secretContainer).setValue(secretId).onChange((value) => { + input.secretId = value; + }); + + const removeButton = row.createEl("button", { + text: "×", + type: "button", + cls: "dtb-remove-button", + }); + removeButton.onclick = () => { + row.remove(); + const index = inputs.indexOf(input); + if (index >= 0) inputs.splice(index, 1); + }; + inputs.push(input); + } + // 创建自定义设置部分 private createCustomSettingsSection(container: HTMLElement) { this.customSettingsSectionContainer = container.createDiv("dtb-section-container"); @@ -610,7 +647,7 @@ export class WallpaperApiEditorModal extends Modal { } // 构建API配置对象 - buildApiConfig(): WallpaperApiConfig { + buildApiConfig(): WallpaperApiConfig | null { const selectedType = this.typeSelect.value; // 收集动态参数 @@ -637,13 +674,28 @@ export class WallpaperApiEditorModal extends Modal { if (this.extraParamsTextarea.value.trim()) { try { const extraParams: unknown = JSON.parse(this.extraParamsTextarea.value); - if (isRecord(extraParams)) { - Object.assign(params, extraParams); - } else { + if (!isRecord(extraParams)) { new Notice(t("api_modal_invalid_json")); + return null; + } + for (const [key, value] of Object.entries(extraParams)) { + if (!isApiValue(value)) { + new Notice(t("api_modal_invalid_extra_param", { key })); + return null; + } + if (paramDescriptorMap.has(key)) { + new Notice(t("api_modal_duplicate_param", { key })); + return null; + } + if (isCredentialParameterKey(key)) { + new Notice(t("api_modal_sensitive_extra_param", { key })); + return null; + } + params[key] = value; } } catch { new Notice(t("api_modal_invalid_json")); + return null; } } for (const descriptor of paramDescriptors) { @@ -663,6 +715,21 @@ export class WallpaperApiEditorModal extends Modal { this.secretParamRefs.forEach((secretId, key) => { if (secretId) paramSecretRefs[key] = secretId; }); + const configuredParamKeys = new Set([...Object.keys(params), ...Object.keys(paramSecretRefs)]); + for (const { key: keyInput, secretId } of this.extraSecretParamInputs) { + const key = keyInput.value.trim(); + if (!key && !secretId) continue; + if (!key || !secretId) { + new Notice(t("api_modal_secret_missing")); + return null; + } + if (configuredParamKeys.has(key)) { + new Notice(t("api_modal_duplicate_param", { key })); + return null; + } + paramSecretRefs[key] = secretId; + configuredParamKeys.add(key); + } const hasParamSecrets = Object.keys(paramSecretRefs).length > 0; const hasHeaderSecrets = Object.keys(headerSecretRefs).length > 0; @@ -761,7 +828,7 @@ export class WallpaperApiEditorModal extends Modal { // 保存API配置 async saveApiConfig(): Promise { const config = this.buildApiConfig(); - if (!this.prepareValidatedConfig(config)) { + if (!config || !this.prepareValidatedConfig(config)) { return; } await this.onSubmit(config); @@ -775,6 +842,10 @@ export class WallpaperApiEditorModal extends Modal { new Notice(t("api_modal_testing_config")); const storedConfig = this.buildApiConfig(); + if (!storedConfig) { + new Notice(t("api_modal_cannot_test_invalid")); + return; + } const runtimeConfig = this.prepareValidatedConfig(storedConfig); if (!runtimeConfig) { new Notice(t("api_modal_cannot_test_invalid")); diff --git a/src/settings/sections/api-settings.ts b/src/settings/sections/api-settings.ts index 59ddf4c..655999d 100644 --- a/src/settings/sections/api-settings.ts +++ b/src/settings/sections/api-settings.ts @@ -114,8 +114,10 @@ export class ApiSettingsSection { items: this.plugin.settings.wallpaperApis, getItemId: (api) => api.id, reorderLabels: { up: t("move_item_up"), down: t("move_item_down") }, - onReorder: async (reorderedApis) => { - this.plugin.settings.wallpaperApis = reorderedApis; + setItems: (apis) => { + this.plugin.settings.wallpaperApis = apis; + }, + onReorder: async () => { await this.plugin.saveSettings(); this.displayWallpaperApis(); }, diff --git a/src/settings/sections/bg-management.ts b/src/settings/sections/bg-management.ts index 282e734..d42f95c 100644 --- a/src/settings/sections/bg-management.ts +++ b/src/settings/sections/bg-management.ts @@ -111,7 +111,7 @@ export class BgManagementSection { items: this.plugin.settings.backgrounds, getItemId: (bg) => bg.id, reorderLabels: { up: t("move_item_up"), down: t("move_item_down") }, - onReorder: async (reorderedBackgrounds) => { + setItems: (reorderedBackgrounds) => { const activeBackgroundId = this.plugin.background?.id; this.plugin.settings.backgrounds = reorderedBackgrounds; if (activeBackgroundId) { @@ -120,6 +120,8 @@ export class BgManagementSection { ); if (activeIndex >= 0) this.plugin.settings.currentIndex = activeIndex; } + }, + onReorder: async () => { await this.plugin.saveSettings(); // 这里仅需刷新背景列表和时间规则列表 this.displayBackgrounds(); diff --git a/src/settings/sections/mode-settings.ts b/src/settings/sections/mode-settings.ts index 1ff8d7f..783b02f 100644 --- a/src/settings/sections/mode-settings.ts +++ b/src/settings/sections/mode-settings.ts @@ -153,8 +153,10 @@ export class ModeSettingsSection { items: this.plugin.settings.timeRules, getItemId: (rule) => rule.id, reorderLabels: { up: t("move_item_up"), down: t("move_item_down") }, - onReorder: async (reorderedRules) => { - this.plugin.settings.timeRules = reorderedRules; + setItems: (rules) => { + this.plugin.settings.timeRules = rules; + }, + onReorder: async () => { await this.plugin.saveSettings(); this.plugin.startBackgroundManager(); this.displayTimeRules(); diff --git a/src/utils/drag-sort.ts b/src/utils/drag-sort.ts index 7263ce5..eb2f4a1 100644 --- a/src/utils/drag-sort.ts +++ b/src/utils/drag-sort.ts @@ -21,6 +21,8 @@ export interface DragSortConfig { reorderLabels?: { up: string; down: string }; /** 排序完成后的回调函数 */ onReorder: (items: T[]) => Promise | void; + /** Publish replacement arrays owned outside this module; also used for rollback. */ + setItems?: (items: T[]) => void; } export type ReorderDirection = -1 | 1; @@ -161,7 +163,9 @@ export class DragSort { const midpoint = rect.top + rect.height / 2; const insertAfter = e.clientY >= midpoint; - void this.reorderItems(draggedId, targetId, insertAfter); + void this.reorderItems(draggedId, targetId, insertAfter).catch((error) => + logger.error("Reorder", error) + ); // 清理样式 element.classList.remove("dtb-drag-over-top", "dtb-drag-over-bottom"); @@ -252,11 +256,13 @@ export class DragSort { const previousItems = [...this.config.items]; this.reorderPending = true; this.config.items.splice(0, this.config.items.length, ...items); + this.config.setItems?.([...items]); try { await this.config.onReorder([...items]); return true; } catch (error) { this.config.items.splice(0, this.config.items.length, ...previousItems); + this.config.setItems?.([...previousItems]); throw error; } finally { this.reorderPending = false; diff --git a/src/wallpaper-apis/core/index.ts b/src/wallpaper-apis/core/index.ts index e7214b7..bb40280 100644 --- a/src/wallpaper-apis/core/index.ts +++ b/src/wallpaper-apis/core/index.ts @@ -6,3 +6,4 @@ export * from "./api-state-manager"; export * from "./base-api"; export * from "./provider-responses"; export * from "./types"; +export * from "./url-params"; diff --git a/src/wallpaper-apis/core/url-params.ts b/src/wallpaper-apis/core/url-params.ts new file mode 100644 index 0000000..6b39dcf --- /dev/null +++ b/src/wallpaper-apis/core/url-params.ts @@ -0,0 +1,13 @@ +import type { WallpaperApiParams } from "./types"; + +/** Adds persisted or runtime-hydrated API parameters without discarding an endpoint's existing query. */ +export function buildWallpaperApiUrl(baseUrl: string, params: WallpaperApiParams): string { + if (Object.keys(params).length === 0) return baseUrl; + + const url = new URL(baseUrl); + for (const [key, value] of Object.entries(params)) { + if (value === "") continue; + url.searchParams.set(key, String(value)); + } + return url.toString(); +} diff --git a/src/wallpaper-apis/providers/custom.ts b/src/wallpaper-apis/providers/custom.ts index 2f83e1b..15373fa 100644 --- a/src/wallpaper-apis/providers/custom.ts +++ b/src/wallpaper-apis/providers/custom.ts @@ -8,6 +8,7 @@ import { generateId } from "../../utils/utils"; import { apiRegistry, BaseWallpaperApi, + buildWallpaperApiUrl, WallpaperApiEndpoints, WallpaperApiParamDescriptor, WallpaperApiParams, @@ -145,7 +146,7 @@ export class CustomApi extends BaseWallpaperApi { } return this.transformCustomResponse( - await this.requestJson(this.baseUrl, { + await this.requestJson(buildWallpaperApiUrl(this.baseUrl, this.params), { allowInsecureHttp: true, headers: this.config.headers, }) diff --git a/tests/credential-storage.test.ts b/tests/credential-storage.test.ts index ae53cde..f43b2ed 100644 --- a/tests/credential-storage.test.ts +++ b/tests/credential-storage.test.ts @@ -7,6 +7,7 @@ import { MissingSecretReferenceError, assertSettingsCredentialsAreReferences, hydrateWallpaperApiConfig, + isCredentialParameterKey, migrateSettingsCredentials, } from "../src/core/credential-storage"; import type { SecretStore, SensitiveParamResolver } from "../src/core/credential-storage"; @@ -75,6 +76,32 @@ function settingsFixture(): DTBSettings { }; } +function customCredentialSettings(): DTBSettings { + const settings = settingsFixture(); + settings.credentialStorageVersion = CURRENT_CREDENTIAL_STORAGE_VERSION; + settings.wallpaperApis = [ + { + id: "custom-query-api", + type: WallpaperApiType.Custom, + enabled: false, + name: "Custom query API", + baseUrl: "https://example.com/images", + params: { api_key: "custom-query-secret", page: 2 }, + customSettings: { imageUrlJsonPath: "$.images[*].url" }, + }, + ]; + return settings; +} + +void test("credential query names cover common custom-provider variants without matching ordinary keys", () => { + for (const key of ["apiKey", "x-api-key", "unsplash_access_key", "auth_token", "refreshToken"]) { + assert.equal(isCredentialParameterKey(key), true, key); + } + for (const key of ["monkey", "keyboard", "page", "category"]) { + assert.equal(isCredentialParameterKey(key), false, key); + } +}); + void test("credential migration replaces password parameters and every custom header with references", () => { const source = settingsFixture(); const store = new FakeSecretStore(); @@ -116,6 +143,19 @@ void test("credential migration is idempotent and reuses an occupied ID only for assert.equal(store.setCalls, callsAfterFirst); }); +void test("credential-like custom parameters migrate even after the original storage migration", () => { + const store = new FakeSecretStore(); + const result = migrateSettingsCredentials(customCredentialSettings(), store, () => []); + const config = result.settings.wallpaperApis[0]; + const secretRef = config.secretRefs?.params?.api_key; + + assert.equal(result.migrated, true); + assert.deepEqual(config.params, { page: 2 }); + assert.ok(secretRef); + assert.equal(store.getSecret(secretRef), "custom-query-secret"); + assertSettingsCredentialsAreReferences(result.settings, () => []); +}); + void test("a failed SecretStorage write leaves the persisted source fully recoverable", () => { const source = settingsFixture(); const before = JSON.stringify(source); @@ -218,3 +258,10 @@ void test("the persistence guard rejects both legacy password fields and custom assert.throws(() => assertSettingsCredentialsAreReferences(source, sensitiveParams), /plaintext credentials/u); }); + +void test("the persistence guard rejects credential-like custom query parameters", () => { + assert.throws( + () => assertSettingsCredentialsAreReferences(customCredentialSettings(), () => []), + /plaintext credentials/u + ); +}); diff --git a/tests/drag-sort.test.ts b/tests/drag-sort.test.ts index e01b701..8fec77b 100644 --- a/tests/drag-sort.test.ts +++ b/tests/drag-sort.test.ts @@ -7,12 +7,17 @@ interface Item { id: string; } -function createSort(items: Item[], onReorder: (reordered: Item[]) => Promise | void): DragSort { +function createSort( + items: Item[], + onReorder: (reordered: Item[]) => Promise | void, + setItems?: (items: Item[]) => void +): DragSort { return new DragSort({ container: {} as HTMLElement, items, getItemId: (item) => item.id, onReorder, + setItems, }); } @@ -68,3 +73,21 @@ void test("accessible reorder restores its local order when persistence rejects" ); assert.equal(sort.canMove(items[0], 1), true); }); + +void test("reorder restores an external collection when persistence rejects", async () => { + const items = [{ id: "a" }, { id: "b" }]; + let publishedItems = items; + const sort = createSort( + items, + () => Promise.reject(new Error("save failed")), + (nextItems) => { + publishedItems = nextItems; + } + ); + + await assert.rejects(sort.moveItem(items[0], 1), /save failed/u); + assert.deepEqual( + publishedItems.map((item) => item.id), + ["a", "b"] + ); +}); diff --git a/tests/release-contract.test.ts b/tests/release-contract.test.ts index 66f1b78..304d3e7 100644 --- a/tests/release-contract.test.ts +++ b/tests/release-contract.test.ts @@ -37,6 +37,12 @@ void test("version authorities and mobile compatibility stay aligned", () => { assert.match(readFileSync(".gitignore", "utf8"), /^main\.js$/mu); }); +void test("older Obsidian releases retain a compatible plugin fallback", () => { + const versions = JSON.parse(readFileSync("versions.json", "utf8")) as Record; + + assert.equal(versions["2.9.2"], "1.7.2"); +}); + void test("developer and agent guidance names tests and the complete gate", () => { const development = readFileSync("docs/development.md", "utf8"); const agents = readFileSync("AGENTS.md", "utf8"); diff --git a/tests/time-rule-scheduler.test.ts b/tests/time-rule-scheduler.test.ts index 7161c77..6c9cc63 100644 --- a/tests/time-rule-scheduler.test.ts +++ b/tests/time-rule-scheduler.test.ts @@ -41,14 +41,12 @@ void test("ignores disabled, malformed, out-of-range, and zero-length rules", () assert.equal(scheduler.parseTimeRule(rule("bad", "1:00", "02:00")), null); }); -void test("resolves overlaps deterministically by start then configuration order", () => { - const first = rule("first", "00:00", "04:00"); - const second = rule("second", "00:00", "03:00"); - const night = rule("night", "22:00", "06:00"); - const scheduler = new TimeRuleScheduler([first, second, night]); +void test("resolves overlaps by configuration order", () => { + const early = rule("early", "00:00", "04:00"); + const overnight = rule("overnight", "22:00", "06:00"); - assert.equal(scheduler.getCurrentRule(localDate(1, 0))?.id, "first"); - assert.equal(scheduler.getCurrentRule(localDate(4, 0))?.id, "night"); + assert.equal(new TimeRuleScheduler([overnight, early]).getCurrentRule(localDate(1, 0))?.id, "overnight"); + assert.equal(new TimeRuleScheduler([early, overnight]).getCurrentRule(localDate(1, 0))?.id, "early"); }); void test("returns the next distinct boundary today or tomorrow", () => { diff --git a/tests/wallpaper-api-url.test.ts b/tests/wallpaper-api-url.test.ts new file mode 100644 index 0000000..c42dc82 --- /dev/null +++ b/tests/wallpaper-api-url.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildWallpaperApiUrl } from "../src/wallpaper-apis/core/url-params"; + +void test("custom API parameters extend existing queries and preserve fragments", () => { + const result = buildWallpaperApiUrl("https://example.com/images?country=cn#results", { + api_key: "a secret/value", + page: 2, + }); + const url = new URL(result); + + assert.equal(url.searchParams.get("country"), "cn"); + assert.equal(url.searchParams.get("api_key"), "a secret/value"); + assert.equal(url.searchParams.get("page"), "2"); + assert.equal(url.hash, "#results"); +}); + +void test("an empty parameter set leaves a custom endpoint byte-for-byte unchanged", () => { + const endpoint = "https://example.com/images?country=cn"; + + assert.equal(buildWallpaperApiUrl(endpoint, {}), endpoint); +}); diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..d44b13d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "2.9.2": "1.7.2" +}