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.
This commit is contained in:
sean2077 2026-07-21 15:12:19 +08:00
parent affeda7894
commit 57ca0e101e
No known key found for this signature in database
GPG key ID: AE28E02B67FB7191
23 changed files with 310 additions and 73 deletions

View file

@ -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.
<!-- agent-scaffold:start — managed by the agent-scaffold skill. Edit project prose OUTSIDE these markers; `agent-scaffold upgrade` refreshes this block. -->

View file

@ -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.

View file

@ -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。

View file

@ -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.41.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.

View file

@ -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,

View file

@ -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");

View file

@ -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);
}
/**

View file

@ -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",

View file

@ -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",

View file

@ -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<string, HTMLElement> = new Map();
secretParamRefs: Map<string, string> = 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<void> {
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"));

View file

@ -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();
},

View file

@ -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();

View file

@ -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();

View file

@ -21,6 +21,8 @@ export interface DragSortConfig<T> {
reorderLabels?: { up: string; down: string };
/** 排序完成后的回调函数 */
onReorder: (items: T[]) => Promise<void> | 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<T> {
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<T> {
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;

View file

@ -6,3 +6,4 @@ export * from "./api-state-manager";
export * from "./base-api";
export * from "./provider-responses";
export * from "./types";
export * from "./url-params";

View file

@ -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();
}

View file

@ -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,
})

View file

@ -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
);
});

View file

@ -7,12 +7,17 @@ interface Item {
id: string;
}
function createSort(items: Item[], onReorder: (reordered: Item[]) => Promise<void> | void): DragSort<Item> {
function createSort(
items: Item[],
onReorder: (reordered: Item[]) => Promise<void> | void,
setItems?: (items: Item[]) => void
): DragSort<Item> {
return new DragSort<Item>({
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"]
);
});

View file

@ -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<string, string>;
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");

View file

@ -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", () => {

View file

@ -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);
});

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"2.9.2": "1.7.2"
}