mirror of
https://github.com/sean2077/obsidian-dynamic-theme-background.git
synced 2026-07-22 06:44:57 +00:00
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:
parent
affeda7894
commit
57ca0e101e
23 changed files with 310 additions and 73 deletions
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export * from "./api-state-manager";
|
|||
export * from "./base-api";
|
||||
export * from "./provider-responses";
|
||||
export * from "./types";
|
||||
export * from "./url-params";
|
||||
|
|
|
|||
13
src/wallpaper-apis/core/url-params.ts
Normal file
13
src/wallpaper-apis/core/url-params.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
23
tests/wallpaper-api-url.test.ts
Normal file
23
tests/wallpaper-api-url.test.ts
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"2.9.2": "1.7.2"
|
||||
}
|
||||
Loading…
Reference in a new issue