mirror of
https://github.com/sean2077/obsidian-dynamic-theme-background.git
synced 2026-07-22 06:44:57 +00:00
Merge branch 'feat/settings-secret-migration'
This commit is contained in:
commit
affeda7894
32 changed files with 1201 additions and 172 deletions
|
|
@ -8,6 +8,7 @@ Dynamic Theme Background is a TypeScript/CSS Obsidian community plugin for deskt
|
|||
- Preserve cross-platform support (`manifest.json` has `isDesktopOnly: false`); Node/Electron-only behavior requires an explicit, guarded desktop boundary.
|
||||
- Keep `src/plugin.ts` as the lifecycle and composition root; background transitions, scheduling, styling, persistence, and internal events belong in the focused services under `src/core/`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
|
||||
## Installation
|
||||
|
||||
> Requires Obsidian 1.7.2 or later.
|
||||
> Requires Obsidian 1.11.4 or later.
|
||||
|
||||
### Marketplace (Pending)
|
||||
|
||||
|
|
@ -78,7 +78,9 @@
|
|||
## Network and Privacy
|
||||
|
||||
- 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 custom headers are stored in the plugin's `data.json` under the vault configuration so Obsidian 1.7.2 remains supported. This file is not encrypted: do not publish it, review vault-sync destinations, and rotate any credential that may have been exposed.
|
||||
- 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.
|
||||
- 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.
|
||||
- Saving a remote wallpaper is an explicit action and writes only to the validated vault-relative folder selected in plugin settings.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
|
||||
## 安装
|
||||
|
||||
> 需要 Obsidian 1.7.2 或更高版本。
|
||||
> 需要 Obsidian 1.11.4 或更高版本。
|
||||
|
||||
### 社区市场(审核中)
|
||||
|
||||
|
|
@ -78,7 +78,9 @@
|
|||
## 网络与隐私
|
||||
|
||||
- 本地图片、纯色和渐变无需请求壁纸服务。启用壁纸提供商、使用自定义 JSON 接口或应用/保存远程图片时,可能访问所配置的第三方;相应服务的条款与隐私政策适用。
|
||||
- 为继续支持 Obsidian 1.7.2,提供商密钥、令牌和自定义请求头保存在 vault 配置目录下的插件 `data.json` 中。该文件未加密:请勿公开,检查 vault 同步目的地,并轮换任何可能泄露的凭据。
|
||||
- 提供商密钥、令牌和所有自定义请求头值通过 Obsidian SecretStorage 保存。插件的 `data.json` 只包含非敏感设置和 SecretStorage ID,不包含这些凭据值。
|
||||
- 升级后首次加载时,旧版明文凭据会先完整写入 SecretStorage,再替换 `data.json`。若任一密钥写入失败,旧 `data.json` 会保持不变并停止插件启动,避免凭据丢失或半迁移。
|
||||
- 密钥可能由多个插件或配置共享,因此删除 DTB API 只会移除引用,不会删除由 Obsidian 管理的密钥。
|
||||
- 插件不包含遥测或分析。诊断日志会清理带凭据的 URL 与请求头、限制嵌套数据规模,并且不会有意记录 API key 或 token。
|
||||
- 保存远程壁纸必须由用户明确触发,且只会写入插件设置中经过校验的 vault 相对目录。
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This guide holds lower-frequency implementation detail referenced by the root `A
|
|||
|
||||
- `src/main.ts` is the bundle entry and re-exports the plugin class from `src/plugin.ts`.
|
||||
- `src/plugin.ts` is the lifecycle and composition root: it loads and saves settings, constructs services, registers commands and UI, and exposes compatibility proxies used by existing surfaces.
|
||||
- `src/core/` owns focused runtime modules: lifecycle coordination, time rules, settings normalization, network and persistence policy, DOM/CSS application, remote image storage, and typed internal notifications.
|
||||
- `src/core/` owns focused runtime modules: lifecycle coordination, time rules, settings normalization and credential migration, network and persistence policy, DOM/CSS application, remote image storage, and typed internal notifications.
|
||||
- `src/types.ts` and `src/default-settings.ts` define the settings contract and defaults; `src/settings/settings-tab.ts` composes the focused sections under `src/settings/sections/`, while `settings-view.ts` owns the alternate settings view.
|
||||
- `src/commands/index.ts` is the command registration point.
|
||||
- `src/wallpaper-apis/core/` owns provider contracts, validation, registry, state, and lifecycle; provider modules under `src/wallpaper-apis/providers/` self-register and are exported through `providers/index.ts`.
|
||||
|
|
@ -35,6 +35,7 @@ Automated tests cover pure policies and structural lifecycle/release contracts.
|
|||
- enable and disable the plugin, confirming timers and the `.dtb-enabled` body class are cleaned up;
|
||||
- exercise the affected manual, interval, or time-rule background path;
|
||||
- save settings, reload the plugin, and confirm existing saved data still behaves correctly;
|
||||
- in a disposable vault with legacy provider credentials, reload once and confirm provider parameters and custom headers still work while `data.json` contains only SecretStorage references; also test a missing reference and confirm that provider stays unavailable without hiding its editable row;
|
||||
- inspect both light and dark themes when CSS variables or overlays change;
|
||||
- exercise provider success, failure, and local-background fallback when wallpaper API behavior changes.
|
||||
|
||||
|
|
@ -44,6 +45,10 @@ 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.
|
||||
|
||||
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`.
|
||||
|
||||
### Commands
|
||||
|
||||
Create or update the command module under `src/commands/`, then keep the central list in `src/commands/index.ts` aligned. Command callbacks that return promises must preserve the repository's no-floating-promise rules.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"id": "dynamic-theme-background",
|
||||
"name": "Dynamic Theme Background",
|
||||
"version": "2.9.2",
|
||||
"minAppVersion": "1.7.2",
|
||||
"minAppVersion": "1.11.4",
|
||||
"description": "Build Your Own Wallpaper Library! Make every note-taking experience visually inspiring.",
|
||||
"author": "sean2077",
|
||||
"authorUrl": "https://github.com/sean2077",
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -16,7 +16,7 @@
|
|||
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||
"globals": "^17.6.0",
|
||||
"jiti": "^2.6.1",
|
||||
"obsidian": "^1.10.3",
|
||||
"obsidian": "^1.13.1",
|
||||
"prettier": "^3.7.3",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||
"globals": "^17.6.0",
|
||||
"jiti": "^2.6.1",
|
||||
"obsidian": "^1.10.3",
|
||||
"obsidian": "^1.13.1",
|
||||
"prettier": "^3.7.3",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
|
|
|
|||
235
src/core/credential-storage.ts
Normal file
235
src/core/credential-storage.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { DTBSettings } from "../types";
|
||||
import type {
|
||||
ApiValueType,
|
||||
WallpaperApiConfig,
|
||||
WallpaperApiSecretRefs,
|
||||
} from "../wallpaper-apis/core/types";
|
||||
|
||||
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;
|
||||
|
||||
export interface SecretStore {
|
||||
getSecret(id: string): string | null;
|
||||
setSecret(id: string, secret: string): void;
|
||||
}
|
||||
|
||||
export type SensitiveParamResolver = (config: WallpaperApiConfig) => readonly string[];
|
||||
|
||||
export interface CredentialMigrationResult {
|
||||
settings: DTBSettings;
|
||||
migrated: boolean;
|
||||
}
|
||||
|
||||
export class CredentialMigrationError extends Error {
|
||||
constructor() {
|
||||
super("Credential migration failed");
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingSecretReferenceError extends Error {
|
||||
constructor(readonly apiId: string, readonly field: string) {
|
||||
super("Credential reference missing");
|
||||
}
|
||||
}
|
||||
|
||||
function cloneSecretRefs(secretRefs: WallpaperApiSecretRefs | undefined): WallpaperApiSecretRefs | undefined {
|
||||
if (!secretRefs) return undefined;
|
||||
return {
|
||||
params: secretRefs.params ? { ...secretRefs.params } : undefined,
|
||||
headers: secretRefs.headers ? { ...secretRefs.headers } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneConfig(config: WallpaperApiConfig): WallpaperApiConfig {
|
||||
return {
|
||||
...config,
|
||||
endpoints: config.endpoints ? { ...config.endpoints } : undefined,
|
||||
headers: config.headers ? { ...config.headers } : undefined,
|
||||
params: { ...config.params },
|
||||
secretRefs: cloneSecretRefs(config.secretRefs),
|
||||
customSettings: config.customSettings ? { ...config.customSettings } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSettings(settings: DTBSettings): DTBSettings {
|
||||
return {
|
||||
...settings,
|
||||
wallpaperApis: settings.wallpaperApis.map(cloneConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSecretIdPart(value: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "");
|
||||
return normalized || "field";
|
||||
}
|
||||
|
||||
function baseSecretId(config: WallpaperApiConfig, kind: "header" | "param", field: string): string {
|
||||
return [SECRET_ID_PREFIX, normalizeSecretIdPart(config.id), kind, normalizeSecretIdPart(field)].join("-");
|
||||
}
|
||||
|
||||
function findAvailableSecretId(
|
||||
baseId: string,
|
||||
secret: string,
|
||||
store: SecretStore,
|
||||
pendingWrites: Map<string, string>
|
||||
): string {
|
||||
let suffix = 1;
|
||||
while (true) {
|
||||
const candidate = suffix === 1 ? baseId : `${baseId}-${suffix}`;
|
||||
const currentValue = pendingWrites.get(candidate) ?? store.getSecret(candidate);
|
||||
if (currentValue === null || currentValue === secret) {
|
||||
return candidate;
|
||||
}
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureReference(
|
||||
config: WallpaperApiConfig,
|
||||
kind: "header" | "param",
|
||||
field: string,
|
||||
secret: string,
|
||||
currentReference: string | undefined,
|
||||
store: SecretStore,
|
||||
pendingWrites: Map<string, string>
|
||||
): string {
|
||||
if (currentReference) {
|
||||
const currentValue = pendingWrites.get(currentReference) ?? store.getSecret(currentReference);
|
||||
if (currentValue !== null && currentValue !== secret) {
|
||||
throw new CredentialMigrationError();
|
||||
}
|
||||
if (currentValue === null) {
|
||||
pendingWrites.set(currentReference, secret);
|
||||
}
|
||||
return currentReference;
|
||||
}
|
||||
|
||||
const id = findAvailableSecretId(baseSecretId(config, kind, field), secret, store, pendingWrites);
|
||||
if ((pendingWrites.get(id) ?? store.getSecret(id)) !== secret) {
|
||||
pendingWrites.set(id, secret);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function compactSecretRefs(secretRefs: WallpaperApiSecretRefs): WallpaperApiSecretRefs | undefined {
|
||||
const params = secretRefs.params && Object.keys(secretRefs.params).length > 0 ? secretRefs.params : undefined;
|
||||
const headers = secretRefs.headers && Object.keys(secretRefs.headers).length > 0 ? secretRefs.headers : undefined;
|
||||
return params || headers ? { params, headers } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies legacy credentials into SecretStorage and returns a sanitized settings clone.
|
||||
* The caller should persist the returned clone only after this function succeeds.
|
||||
*/
|
||||
export function migrateSettingsCredentials(
|
||||
source: DTBSettings,
|
||||
store: SecretStore,
|
||||
resolveSensitiveParams: SensitiveParamResolver
|
||||
): CredentialMigrationResult {
|
||||
const settings = cloneSettings(source);
|
||||
const pendingWrites = new Map<string, string>();
|
||||
let migrated = settings.credentialStorageVersion < CURRENT_CREDENTIAL_STORAGE_VERSION;
|
||||
|
||||
try {
|
||||
for (const config of settings.wallpaperApis) {
|
||||
const refs = cloneSecretRefs(config.secretRefs) ?? {};
|
||||
refs.params ??= {};
|
||||
refs.headers ??= {};
|
||||
|
||||
for (const field of resolveSensitiveParams(config)) {
|
||||
if (!(field in config.params)) continue;
|
||||
const rawValue = config.params[field];
|
||||
delete config.params[field];
|
||||
migrated = true;
|
||||
if (rawValue === "") continue;
|
||||
const secret = String(rawValue);
|
||||
refs.params[field] = ensureReference(
|
||||
config,
|
||||
"param",
|
||||
field,
|
||||
secret,
|
||||
refs.params[field],
|
||||
store,
|
||||
pendingWrites
|
||||
);
|
||||
}
|
||||
|
||||
for (const [field, secret] of Object.entries(config.headers ?? {})) {
|
||||
migrated = true;
|
||||
if (secret === "") continue;
|
||||
refs.headers[field] = ensureReference(
|
||||
config,
|
||||
"header",
|
||||
field,
|
||||
secret,
|
||||
refs.headers[field],
|
||||
store,
|
||||
pendingWrites
|
||||
);
|
||||
}
|
||||
|
||||
config.headers = undefined;
|
||||
config.secretRefs = compactSecretRefs(refs);
|
||||
}
|
||||
|
||||
for (const [id, secret] of pendingWrites) {
|
||||
if (!SECRET_ID_PATTERN.test(id)) {
|
||||
throw new CredentialMigrationError();
|
||||
}
|
||||
store.setSecret(id, secret);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialMigrationError) throw error;
|
||||
throw new CredentialMigrationError();
|
||||
}
|
||||
|
||||
settings.credentialStorageVersion = CURRENT_CREDENTIAL_STORAGE_VERSION;
|
||||
return { settings, migrated };
|
||||
}
|
||||
|
||||
function hydrateReferences(
|
||||
target: Record<string, ApiValueType> | Record<string, string>,
|
||||
refs: Record<string, string> | undefined,
|
||||
config: WallpaperApiConfig,
|
||||
store: SecretStore,
|
||||
kind: "header" | "param"
|
||||
): void {
|
||||
for (const [field, id] of Object.entries(refs ?? {})) {
|
||||
const secret = store.getSecret(id);
|
||||
if (secret === null) {
|
||||
throw new MissingSecretReferenceError(config.id, `${kind}:${field}`);
|
||||
}
|
||||
target[field] = secret;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a short-lived runtime clone containing the resolved credential values. */
|
||||
export function hydrateWallpaperApiConfig(config: WallpaperApiConfig, store: SecretStore): WallpaperApiConfig {
|
||||
const hydrated = cloneConfig(config);
|
||||
hydrated.headers ??= {};
|
||||
hydrateReferences(hydrated.params, hydrated.secretRefs?.params, config, store, "param");
|
||||
hydrateReferences(hydrated.headers, hydrated.secretRefs?.headers, config, store, "header");
|
||||
if (Object.keys(hydrated.headers).length === 0) {
|
||||
hydrated.headers = undefined;
|
||||
}
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
/** Prevents any future settings write from reintroducing plaintext credentials. */
|
||||
export function assertSettingsCredentialsAreReferences(
|
||||
settings: DTBSettings,
|
||||
resolveSensitiveParams: SensitiveParamResolver
|
||||
): void {
|
||||
for (const config of settings.wallpaperApis) {
|
||||
const hasSensitiveParam = resolveSensitiveParams(config).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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
export { BackgroundManager } from "./background-manager";
|
||||
export {
|
||||
MissingSecretReferenceError,
|
||||
assertSettingsCredentialsAreReferences,
|
||||
hydrateWallpaperApiConfig,
|
||||
migrateSettingsCredentials,
|
||||
} from "./credential-storage";
|
||||
export { selectBackgroundPlan } from "./background-selection";
|
||||
export type { BackgroundSelectionPlan } from "./background-selection";
|
||||
export { BackgroundPersistence } from "./background-persistence";
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ type ImperativeSettingSurface = {
|
|||
display(): void;
|
||||
};
|
||||
|
||||
type DualSettingSurface = ImperativeSettingSurface & {
|
||||
update(): void;
|
||||
};
|
||||
|
||||
/** Preserve visible slider values on supported Obsidian versions before 1.13. */
|
||||
export function preserveSliderValueVisibility(slider: SliderComponent): SliderComponent {
|
||||
if (!requireApiVersion("1.13.0")) {
|
||||
|
|
@ -21,3 +25,12 @@ export function preserveSliderValueVisibility(slider: SliderComponent): SliderCo
|
|||
export function displayImperativeSettings(surface: ImperativeSettingSurface): void {
|
||||
surface.display();
|
||||
}
|
||||
|
||||
/** Refresh declarative definitions on 1.13+, while preserving the older imperative path. */
|
||||
export function refreshDualSettings(surface: DualSettingSurface): void {
|
||||
if (requireApiVersion("1.13.0")) {
|
||||
surface.update();
|
||||
} else {
|
||||
surface.display();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,11 +91,20 @@ function isWallpaperApi(value: unknown): value is WallpaperApiConfig {
|
|||
isOptionalString(value.description) &&
|
||||
isValueRecord(value.params, isApiValue) &&
|
||||
(value.headers === undefined || isValueRecord(value.headers, (entry) => typeof entry === "string")) &&
|
||||
(value.secretRefs === undefined || isSecretRefs(value.secretRefs)) &&
|
||||
(value.endpoints === undefined || isValueRecord(value.endpoints, isOptionalString)) &&
|
||||
(value.customSettings === undefined || isValueRecord(value.customSettings, isOptionalString))
|
||||
);
|
||||
}
|
||||
|
||||
function isSecretRefs(value: unknown): boolean {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
(value.params === undefined || isValueRecord(value.params, (entry) => typeof entry === "string")) &&
|
||||
(value.headers === undefined || isValueRecord(value.headers, (entry) => typeof entry === "string"))
|
||||
);
|
||||
}
|
||||
|
||||
function arrayOrDefault<T>(value: unknown, fallback: T[], predicate: (entry: unknown) => entry is T): T[] {
|
||||
return Array.isArray(value) && value.every(predicate) ? clone(value) : fallback;
|
||||
}
|
||||
|
|
@ -139,6 +148,12 @@ export function normalizeSettings(loaded: unknown, defaults: DTBSettings): DTBSe
|
|||
);
|
||||
|
||||
return {
|
||||
credentialStorageVersion: integerOrDefault(
|
||||
loaded.credentialStorageVersion,
|
||||
0,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
),
|
||||
enabled: booleanOrDefault(loaded.enabled, fallback.enabled),
|
||||
statusBarEnabled: booleanOrDefault(loaded.statusBarEnabled, fallback.statusBarEnabled),
|
||||
blurDepth: numberOrDefault(loaded.blurDepth, fallback.blurDepth, 0, 30),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { t } from "./i18n";
|
||||
import { CURRENT_CREDENTIAL_STORAGE_VERSION } from "./core/credential-storage";
|
||||
import type { DTBSettings } from "./types";
|
||||
import { apiRegistry, WallpaperApiType } from "./wallpaper-apis";
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ let DEFAULT_SETTINGS: DTBSettings;
|
|||
|
||||
function genDefaultSettings(): DTBSettings {
|
||||
DEFAULT_SETTINGS = {
|
||||
credentialStorageVersion: CURRENT_CREDENTIAL_STORAGE_VERSION,
|
||||
enabled: true,
|
||||
statusBarEnabled: true, // 是否激活状态栏
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default {
|
|||
reset_time_rules_button: "Restore defaults",
|
||||
reset_time_rules_tooltip: "Reset the time slot rules to their default settings.",
|
||||
time_rule_hint:
|
||||
"💡 When time slot rules overlap, the first matching rule takes precedence. The indicator 🔥 means the current active rule.",
|
||||
"💡 Drag rules or use the up/down buttons to reorder them. When rules overlap, the first match wins; 🔥 marks the active rule.",
|
||||
rule_name_label: "Rule name:",
|
||||
start_time_label: "Start time (hh:mm):",
|
||||
end_time_label: "End time (hh:mm):",
|
||||
|
|
@ -106,8 +106,10 @@ export default {
|
|||
gradient_css_label: "CSS gradient, for example linear-gradient(45deg, #ff0000, #0000ff):",
|
||||
select_background_option: "Select background",
|
||||
background_management_hint:
|
||||
"💡 Tip: you can drag background items to reorder them; the background with 🔥 indicator is the currently applied background.",
|
||||
"💡 Drag backgrounds or use the up/down buttons to reorder them; 🔥 marks the currently applied background.",
|
||||
drag_handle_tooltip: "Drag to reorder",
|
||||
move_item_up: "Move up",
|
||||
move_item_down: "Move down",
|
||||
random_wallpaper_settings_title: "Random wallpaper",
|
||||
enable_random_wallpaper_name: "Enable random wallpaper",
|
||||
enable_random_wallpaper_desc:
|
||||
|
|
@ -125,7 +127,7 @@ export default {
|
|||
"Restore the default wallpaper APIs provided by the plugin (will not overwrite existing APIs)",
|
||||
restore_default_apis_success: "🎉 Successfully restored default wallpaper APIs",
|
||||
wallpaper_api_hint:
|
||||
"💡 You can add multiple API instances of the same type with different parameters to get different types of wallpapers.",
|
||||
"💡 Add multiple API instances with different parameters; drag them or use the up/down buttons to reorder them.",
|
||||
add_api_bg_tooltip: "Click to add a new wallpaper from API",
|
||||
wallpaper_api_url_name: "Wallpaper API URL",
|
||||
wallpaper_api_url_desc:
|
||||
|
|
@ -218,10 +220,11 @@ export default {
|
|||
api_modal_test_rejected: "The provider rejected the configuration or could not connect",
|
||||
api_modal_test_unexpected: "An unexpected error occurred; check the developer console",
|
||||
api_modal_api_url: "API URL",
|
||||
api_modal_headers_optional: "Headers (optional)",
|
||||
api_modal_headers_optional: "Header secrets (optional)",
|
||||
api_modal_add_header: "Add header",
|
||||
api_modal_header_key: "Header key",
|
||||
api_modal_header_value: "Header value",
|
||||
api_modal_header_secret: "Obsidian secret",
|
||||
api_modal_secret_missing: "Choose an Obsidian secret for every required credential and header.",
|
||||
api_modal_api_parameters: "API parameters",
|
||||
api_modal_api_documentation: "📖 API documentation",
|
||||
api_modal_token_url: "🔑 Token URL",
|
||||
|
|
@ -242,6 +245,10 @@ export default {
|
|||
api_modal_image_url_json_path: "Image URL JSON path",
|
||||
api_modal_no_description: "No description provided.",
|
||||
api_modal_unnamed_api: "Unnamed API",
|
||||
notice_api_secret_missing: "A referenced Obsidian secret is missing. Edit the API configuration.",
|
||||
notice_api_secret_unavailable: "Wallpaper API credentials are unavailable. Edit the API configuration.",
|
||||
notice_credential_migration_failed:
|
||||
"DTB could not move API credentials to Obsidian SecretStorage. Existing settings were left unchanged and plugin startup stopped.",
|
||||
api_initialized_notice: "{apiName} initialized: {count} wallpapers found across {pages} pages.",
|
||||
|
||||
// ===== 状态文本 =====
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default {
|
|||
clear_time_rules_tooltip: "清除所有时段规则",
|
||||
reset_time_rules_button: "恢复默认",
|
||||
reset_time_rules_tooltip: "将时段规则重置为默认设置",
|
||||
time_rule_hint: "💡 当时段规则存在重叠,优先使用第一个匹配的规则;带有 🔥 标识的规则为当前应用的规则",
|
||||
time_rule_hint: "💡 可拖拽或使用上移/下移按钮排序;规则重叠时优先使用第一个匹配项,🔥 表示当前规则",
|
||||
rule_name_label: "规则名称:",
|
||||
start_time_label: "开始时间(HH:MM):",
|
||||
end_time_label: "结束时间(HH:MM):",
|
||||
|
|
@ -97,8 +97,10 @@ export default {
|
|||
color_value_label: "颜色值(如 #ff0000):",
|
||||
gradient_css_label: "CSS 渐变(如 linear-gradient(45deg, #ff0000, #0000ff)):",
|
||||
select_background_option: "选择背景",
|
||||
background_management_hint: "💡 提示:您可以拖拽背景项目来重新排序;带有 🔥 标识的背景为当前应用的背景",
|
||||
background_management_hint: "💡 可拖拽或使用上移/下移按钮排序背景;🔥 表示当前应用的背景",
|
||||
drag_handle_tooltip: "拖拽以重新排序",
|
||||
move_item_up: "上移",
|
||||
move_item_down: "下移",
|
||||
random_wallpaper_settings_title: "随机壁纸设置",
|
||||
enable_random_wallpaper_name: "启用随机壁纸",
|
||||
enable_random_wallpaper_desc: "启用后,将从壁纸网站 API 和背景列表中获取随机壁纸,否则将从背景列表按顺序获取",
|
||||
|
|
@ -113,7 +115,7 @@ export default {
|
|||
add_api_button: "添加 API",
|
||||
restore_default_apis_tooltip: "恢复插件提供的默认壁纸 API(不会覆盖现有的 API)",
|
||||
restore_default_apis_success: "🎉 成功恢复默认壁纸 API",
|
||||
wallpaper_api_hint: "💡 您可以创建同一种类型的 API 但参数不同的多个 API 实例,以获取不同类型的壁纸",
|
||||
wallpaper_api_hint: "💡 可创建参数不同的多个 API 实例,并通过拖拽或上移/下移按钮排序",
|
||||
add_api_bg_tooltip: "点击从 API 添加新的壁纸",
|
||||
wallpaper_api_url_name: "壁纸 API 地址",
|
||||
wallpaper_api_url_desc: "支持 Unsplash、Pixabay、Pexels 等 API,默认使用 Unsplash API(需要替换 YOUR_ACCESS_KEY)",
|
||||
|
|
@ -201,10 +203,11 @@ export default {
|
|||
api_modal_test_rejected: "提供商拒绝了该配置或无法连接",
|
||||
api_modal_test_unexpected: "发生意外错误,请检查开发者控制台",
|
||||
api_modal_api_url: "API 地址",
|
||||
api_modal_headers_optional: "请求头(可选)",
|
||||
api_modal_headers_optional: "请求头密钥(可选)",
|
||||
api_modal_add_header: "添加请求头",
|
||||
api_modal_header_key: "请求头键",
|
||||
api_modal_header_value: "请求头值",
|
||||
api_modal_header_secret: "Obsidian 密钥",
|
||||
api_modal_secret_missing: "请为每个必需凭据和请求头选择一个 Obsidian 密钥。",
|
||||
api_modal_api_parameters: "API 参数",
|
||||
api_modal_api_documentation: "📖 API 文档",
|
||||
api_modal_token_url: "🔑 Token 获取地址",
|
||||
|
|
@ -225,6 +228,10 @@ export default {
|
|||
api_modal_image_url_json_path: "图片 URL JSON 路径",
|
||||
api_modal_no_description: "未提供描述。",
|
||||
api_modal_unnamed_api: "未命名的 API",
|
||||
notice_api_secret_missing: "引用的 Obsidian 密钥不存在,请编辑 API 配置。",
|
||||
notice_api_secret_unavailable: "壁纸 API 凭据不可用,请编辑 API 配置。",
|
||||
notice_credential_migration_failed:
|
||||
"DTB 无法将 API 凭据迁移到 Obsidian SecretStorage。原设置保持不变,插件启动已停止。",
|
||||
api_initialized_notice: "{apiName} 初始化完成:在 {pages} 页中找到 {count} 张壁纸。",
|
||||
|
||||
// ===== 状态文本 =====
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { App, Modal, Notice } from "obsidian";
|
||||
import { Modal, Notice, SecretComponent } from "obsidian";
|
||||
import { logger } from "../core/logger";
|
||||
import type DynamicThemeBackgroundPlugin from "../plugin";
|
||||
import { generateId } from "../utils/utils";
|
||||
import { isRecord } from "../utils/type-guards";
|
||||
import { t } from "../i18n";
|
||||
|
|
@ -18,8 +19,9 @@ import {
|
|||
* 壁纸 API 编辑模态框类,用于在 Obsidian 插件中创建和编辑壁纸 API 配置。
|
||||
*/
|
||||
export class WallpaperApiEditorModal extends Modal {
|
||||
plugin: DynamicThemeBackgroundPlugin;
|
||||
apiConfig: WallpaperApiConfig;
|
||||
onSubmit: (apiConfig: WallpaperApiConfig) => void;
|
||||
onSubmit: (apiConfig: WallpaperApiConfig) => void | Promise<void>;
|
||||
|
||||
// 基础配置输入元素
|
||||
nameInput!: HTMLInputElement;
|
||||
|
|
@ -28,12 +30,13 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
urlInput!: HTMLInputElement;
|
||||
// Headers配置
|
||||
headersContainer!: HTMLDivElement;
|
||||
headerInputs: Array<{ key: HTMLInputElement; value: HTMLInputElement }> = [];
|
||||
headerInputs: Array<{ key: HTMLInputElement; secretId: string }> = [];
|
||||
|
||||
// 参数配置容器的引用
|
||||
paramsSectionContainer!: HTMLElement;
|
||||
// 动态参数输入元素映射
|
||||
paramInputs: Map<string, HTMLElement> = new Map();
|
||||
secretParamRefs: Map<string, string> = new Map();
|
||||
// 额外参数输入
|
||||
extraParamsTextarea!: HTMLTextAreaElement;
|
||||
|
||||
|
|
@ -42,8 +45,13 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
// 自定义设置输入元素
|
||||
customSettingsInputs: Map<string, HTMLElement> = new Map();
|
||||
|
||||
constructor(app: App, apiConfig: WallpaperApiConfig, onSubmit: (apiConfig: WallpaperApiConfig) => void) {
|
||||
super(app);
|
||||
constructor(
|
||||
plugin: DynamicThemeBackgroundPlugin,
|
||||
apiConfig: WallpaperApiConfig,
|
||||
onSubmit: (apiConfig: WallpaperApiConfig) => void | Promise<void>
|
||||
) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
this.apiConfig = apiConfig;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
|
@ -138,10 +146,10 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
container.createEl("label", { text: t("api_modal_headers_optional") });
|
||||
this.headersContainer = container.createDiv("dtb-list-container");
|
||||
|
||||
// 渲染已有的headers
|
||||
if (this.apiConfig.headers) {
|
||||
Object.entries(this.apiConfig.headers).forEach(([key, value]) => {
|
||||
this.addHeaderInput(key, value);
|
||||
// 渲染已有的 SecretStorage 引用
|
||||
if (this.apiConfig.secretRefs?.headers) {
|
||||
Object.entries(this.apiConfig.secretRefs.headers).forEach(([key, secretId]) => {
|
||||
this.addHeaderInput(key, secretId);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +163,7 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
}
|
||||
|
||||
// 添加header输入行
|
||||
private addHeaderInput(key = "", value = "") {
|
||||
private addHeaderInput(key = "", secretId = "") {
|
||||
const headerRow = this.headersContainer.createDiv("dtb-list-row");
|
||||
|
||||
const keyInput = headerRow.createEl("input", {
|
||||
|
|
@ -165,11 +173,11 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
cls: "dtb-input",
|
||||
});
|
||||
|
||||
const valueInput = headerRow.createEl("input", {
|
||||
type: "text",
|
||||
value: value,
|
||||
placeholder: t("api_modal_header_value"),
|
||||
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", {
|
||||
|
|
@ -185,7 +193,7 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
}
|
||||
};
|
||||
|
||||
this.headerInputs.push({ key: keyInput, value: valueInput });
|
||||
this.headerInputs.push(headerInput);
|
||||
}
|
||||
|
||||
// 创建参数配置部分
|
||||
|
|
@ -198,6 +206,7 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
private refreshParamsSection() {
|
||||
// 清除现有的参数输入
|
||||
this.paramInputs.clear();
|
||||
this.secretParamRefs.clear();
|
||||
|
||||
// 使用保存的容器引用而不是DOM查询
|
||||
if (!this.paramsSectionContainer) return;
|
||||
|
|
@ -291,11 +300,20 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
|
||||
// 创建动态参数输入
|
||||
private createDynamicParamInputs(container: HTMLElement, descriptors: WallpaperApiParamDescriptor[]) {
|
||||
const selectedType = this.typeSelect.value as WallpaperApiType;
|
||||
this.createDynamicInputs(
|
||||
container,
|
||||
descriptors,
|
||||
(key) => this.apiConfig.params[key],
|
||||
(key, input) => this.paramInputs.set(key, input)
|
||||
(key, input) => this.paramInputs.set(key, input),
|
||||
(key) => (selectedType === this.apiConfig.type ? this.apiConfig.secretRefs?.params?.[key] : undefined),
|
||||
(key, secretId) => {
|
||||
if (secretId) {
|
||||
this.secretParamRefs.set(key, secretId);
|
||||
} else {
|
||||
this.secretParamRefs.delete(key);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -308,7 +326,7 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
});
|
||||
|
||||
// 构建额外参数JSON
|
||||
const knownKeys = Array.from(this.paramInputs.keys());
|
||||
const knownKeys = this.getParamDescriptorsForType(this.typeSelect.value).map((descriptor) => descriptor.key);
|
||||
const extraParams: Record<string, string | number | boolean | string[]> = {};
|
||||
|
||||
Object.entries(this.apiConfig.params).forEach(([key, value]) => {
|
||||
|
|
@ -381,7 +399,9 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
container: HTMLElement,
|
||||
descriptors: WallpaperApiParamDescriptor[],
|
||||
getCurrentValue: (key: string) => OptionalApiValueType,
|
||||
setInput: (key: string, input: HTMLElement) => void
|
||||
setInput: (key: string, input: HTMLElement) => void,
|
||||
getCurrentSecretRef?: (key: string) => string | undefined,
|
||||
setSecretRef?: (key: string, secretId: string) => void
|
||||
) {
|
||||
descriptors.forEach((descriptor) => {
|
||||
const paramContainer = container.createDiv("dtb-field");
|
||||
|
|
@ -396,6 +416,17 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
});
|
||||
}
|
||||
|
||||
if (descriptor.type === "password" && getCurrentSecretRef && setSecretRef) {
|
||||
const currentRef = getCurrentSecretRef(descriptor.key) ?? "";
|
||||
const secretContainer = paramContainer.createDiv("dtb-secret-control");
|
||||
secretContainer.setAttribute("aria-label", descriptor.label);
|
||||
setSecretRef(descriptor.key, currentRef);
|
||||
new SecretComponent(this.app, secretContainer).setValue(currentRef).onChange((secretId) => {
|
||||
setSecretRef(descriptor.key, secretId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用转换函数处理当前值,转为 UI 显示值
|
||||
let currentValue: OptionalUiValueType = getCurrentValue(descriptor.key) ?? descriptor.defaultValue;
|
||||
if (descriptor.fromApiValue && currentValue !== undefined) {
|
||||
|
|
@ -443,7 +474,6 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
break;
|
||||
case "password":
|
||||
input = this.createStringInput(container, descriptor, currentValue);
|
||||
(input as HTMLInputElement).type = "password";
|
||||
break;
|
||||
default: // string
|
||||
input = this.createStringInput(container, descriptor, currentValue);
|
||||
|
|
@ -574,8 +604,8 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
text: t("api_modal_save"),
|
||||
cls: ["dtb-button", "mod-cta"],
|
||||
});
|
||||
submitButton.onclick = () => {
|
||||
this.saveApiConfig();
|
||||
submitButton.onclick = async () => {
|
||||
await this.saveApiConfig();
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -616,14 +646,25 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
new Notice(t("api_modal_invalid_json"));
|
||||
}
|
||||
}
|
||||
for (const descriptor of paramDescriptors) {
|
||||
if (descriptor.type === "password") {
|
||||
delete params[descriptor.key];
|
||||
}
|
||||
}
|
||||
|
||||
// 收集headers
|
||||
const headers: Record<string, string> = {};
|
||||
this.headerInputs.forEach(({ key, value }) => {
|
||||
if (key.value.trim() && value.value.trim()) {
|
||||
headers[key.value.trim()] = value.value.trim();
|
||||
// 收集 SecretStorage 引用;请求头值本身不会进入配置对象
|
||||
const headerSecretRefs: Record<string, string> = {};
|
||||
this.headerInputs.forEach(({ key, secretId }) => {
|
||||
if (key.value.trim() && secretId) {
|
||||
headerSecretRefs[key.value.trim()] = secretId;
|
||||
}
|
||||
});
|
||||
const paramSecretRefs: Record<string, string> = {};
|
||||
this.secretParamRefs.forEach((secretId, key) => {
|
||||
if (secretId) paramSecretRefs[key] = secretId;
|
||||
});
|
||||
const hasParamSecrets = Object.keys(paramSecretRefs).length > 0;
|
||||
const hasHeaderSecrets = Object.keys(headerSecretRefs).length > 0;
|
||||
|
||||
return {
|
||||
id: this.apiConfig.id || generateId("api"),
|
||||
|
|
@ -633,7 +674,13 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
baseUrl: this.urlInput.value,
|
||||
enabled: this.apiConfig.enabled ?? false, // 默认不启用
|
||||
params,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
secretRefs:
|
||||
hasParamSecrets || hasHeaderSecrets
|
||||
? {
|
||||
params: hasParamSecrets ? paramSecretRefs : undefined,
|
||||
headers: hasHeaderSecrets ? headerSecretRefs : undefined,
|
||||
}
|
||||
: undefined,
|
||||
customSettings: Object.keys(customSettings).length > 0 ? customSettings : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -693,23 +740,31 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
return result;
|
||||
}
|
||||
|
||||
validateApiConfig(config: WallpaperApiConfig): boolean {
|
||||
private prepareValidatedConfig(config: WallpaperApiConfig): WallpaperApiConfig | null {
|
||||
let runtimeConfig: WallpaperApiConfig;
|
||||
try {
|
||||
runtimeConfig = this.plugin.prepareWallpaperApiConfig(config);
|
||||
} catch {
|
||||
new Notice(t("api_modal_secret_missing"));
|
||||
return null;
|
||||
}
|
||||
|
||||
// 调用 API 注册器验证参数
|
||||
const validation = apiRegistry.validateParams(config.type, config.params);
|
||||
const validation = apiRegistry.validateParams(runtimeConfig.type, runtimeConfig.params);
|
||||
if (!validation.valid) {
|
||||
new Notice(t("api_modal_invalid_params", { errors: validation.errors?.join(", ") ?? "Unknown error" }));
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return true;
|
||||
return runtimeConfig;
|
||||
}
|
||||
|
||||
// 保存API配置
|
||||
saveApiConfig() {
|
||||
async saveApiConfig(): Promise<void> {
|
||||
const config = this.buildApiConfig();
|
||||
if (!this.validateApiConfig(config)) {
|
||||
if (!this.prepareValidatedConfig(config)) {
|
||||
return;
|
||||
}
|
||||
this.onSubmit(config);
|
||||
await this.onSubmit(config);
|
||||
this.close();
|
||||
}
|
||||
|
||||
|
|
@ -719,15 +774,16 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
try {
|
||||
new Notice(t("api_modal_testing_config"));
|
||||
|
||||
const config = this.buildApiConfig();
|
||||
if (!this.validateApiConfig(config)) {
|
||||
const storedConfig = this.buildApiConfig();
|
||||
const runtimeConfig = this.prepareValidatedConfig(storedConfig);
|
||||
if (!runtimeConfig) {
|
||||
new Notice(t("api_modal_cannot_test_invalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用临时 ID 避免覆盖同 ID 的已有实例
|
||||
testApiId = generateId("api-test");
|
||||
const testConfig = { ...config, enabled: false, id: testApiId };
|
||||
const testConfig = { ...runtimeConfig, enabled: false, id: testApiId };
|
||||
|
||||
await apiManager.createApi(testConfig, false);
|
||||
const success = apiManager.getApiById(testApiId) ? await apiManager.enableApi(testApiId) : false;
|
||||
|
|
|
|||
|
|
@ -5,21 +5,32 @@
|
|||
import { Notice, Plugin } from "obsidian";
|
||||
|
||||
import { registerCommands } from "./commands";
|
||||
import { displayImperativeSettings } from "./core/obsidian-compat";
|
||||
import {
|
||||
BackgroundManager,
|
||||
BackgroundPersistence,
|
||||
EventBus,
|
||||
MissingSecretReferenceError,
|
||||
StyleManager,
|
||||
TimeRuleScheduler,
|
||||
assertSettingsCredentialsAreReferences,
|
||||
hydrateWallpaperApiConfig,
|
||||
logger,
|
||||
migrateSettingsCredentials,
|
||||
normalizeSettings,
|
||||
} from "./core";
|
||||
import { getDefaultSettings } from "./default-settings";
|
||||
import { t } from "./i18n";
|
||||
import { DTBSettingTab, DTBSettingsView, DTB_SETTINGS_VIEW_TYPE } from "./settings";
|
||||
import type { BackgroundItem, DTBSettings } from "./types";
|
||||
import { apiManager } from "./wallpaper-apis";
|
||||
import { apiManager, apiRegistry } from "./wallpaper-apis";
|
||||
import type { WallpaperApiConfig } from "./wallpaper-apis";
|
||||
|
||||
function sensitiveParamKeys(config: WallpaperApiConfig): string[] {
|
||||
return apiRegistry
|
||||
.getParamDescriptors(config.type)
|
||||
.filter((descriptor) => descriptor.type === "password")
|
||||
.map((descriptor) => descriptor.key);
|
||||
}
|
||||
|
||||
export default class DynamicThemeBackgroundPlugin extends Plugin {
|
||||
settings!: DTBSettings;
|
||||
|
|
@ -94,7 +105,7 @@ export default class DynamicThemeBackgroundPlugin extends Plugin {
|
|||
private async initializeRuntime(generation: number): Promise<void> {
|
||||
for (const apiConfig of this.settings.wallpaperApis) {
|
||||
if (generation !== this.lifecycleGeneration) return;
|
||||
await apiManager.createApi(apiConfig, false);
|
||||
await this.createWallpaperApi(apiConfig, false);
|
||||
}
|
||||
if (generation === this.lifecycleGeneration && this.settings.enabled) {
|
||||
this.startBackgroundManager();
|
||||
|
|
@ -104,13 +115,55 @@ export default class DynamicThemeBackgroundPlugin extends Plugin {
|
|||
async loadSettings() {
|
||||
const defaultSettings = getDefaultSettings();
|
||||
const loaded: unknown = await this.loadData();
|
||||
this.settings = normalizeSettings(loaded, defaultSettings);
|
||||
const normalized = normalizeSettings(loaded, defaultSettings);
|
||||
|
||||
try {
|
||||
const migration = migrateSettingsCredentials(normalized, this.app.secretStorage, sensitiveParamKeys);
|
||||
this.settings = migration.settings;
|
||||
if (migration.migrated) {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
} catch (error) {
|
||||
this.settings = normalized;
|
||||
new Notice(t("notice_credential_migration_failed"), 0);
|
||||
logger.error("Wallpaper API credential migration failed; existing settings were not overwritten");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
assertSettingsCredentialsAreReferences(this.settings, sensitiveParamKeys);
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
prepareWallpaperApiConfig(config: WallpaperApiConfig): WallpaperApiConfig {
|
||||
return hydrateWallpaperApiConfig(config, this.app.secretStorage);
|
||||
}
|
||||
|
||||
async createWallpaperApi(config: WallpaperApiConfig, activate = config.enabled): Promise<boolean> {
|
||||
let runtimeConfig: WallpaperApiConfig;
|
||||
try {
|
||||
runtimeConfig = this.prepareWallpaperApiConfig(config);
|
||||
} catch (error) {
|
||||
await apiManager.deleteApi(config.id);
|
||||
const message =
|
||||
error instanceof MissingSecretReferenceError
|
||||
? t("notice_api_secret_missing")
|
||||
: t("notice_api_secret_unavailable");
|
||||
apiManager.stateManager.notify(config.id, {
|
||||
configEnabled: false,
|
||||
instanceEnabled: false,
|
||||
isLoading: false,
|
||||
error: message,
|
||||
});
|
||||
logger.warn("A wallpaper API was not created because its credentials are unavailable");
|
||||
return false;
|
||||
}
|
||||
|
||||
await apiManager.createApi(runtimeConfig, activate);
|
||||
return apiManager.getApiById(config.id) !== undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 背景管理器代理
|
||||
// ============================================================================
|
||||
|
|
@ -301,7 +354,7 @@ export default class DynamicThemeBackgroundPlugin extends Plugin {
|
|||
refreshActiveSettings() {
|
||||
this.settingTabs.forEach((tab) => {
|
||||
if (tab.isActive()) {
|
||||
displayImperativeSettings(tab);
|
||||
tab.refresh();
|
||||
}
|
||||
});
|
||||
this.events.emit("settings:changed", { key: "enabled", value: this.settings.enabled });
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
WallpaperApiConfig,
|
||||
WallpaperApiType,
|
||||
apiManager,
|
||||
apiRegistry,
|
||||
} from "../../wallpaper-apis";
|
||||
|
||||
export class ApiSettingsSection {
|
||||
|
|
@ -70,7 +71,7 @@ export class ApiSettingsSection {
|
|||
.addExtraButton((button) => {
|
||||
button.setIcon("refresh-cw");
|
||||
button.setTooltip(t("restore_default_apis_tooltip"));
|
||||
button.onClick(() => {
|
||||
button.onClick(async () => {
|
||||
// 重新生成默认设置以获取最新的默认 API
|
||||
const defaultApis = this.defaultSettings.wallpaperApis;
|
||||
|
||||
|
|
@ -80,12 +81,12 @@ export class ApiSettingsSection {
|
|||
if (!existingApi) {
|
||||
// 如果不存在,则添加并创建 API 实例
|
||||
this.plugin.settings.wallpaperApis.push(apiConfig);
|
||||
void apiManager.createApi(apiConfig, this.plugin.settings.enabled);
|
||||
await this.plugin.createWallpaperApi(apiConfig, this.plugin.settings.enabled);
|
||||
}
|
||||
}
|
||||
new Notice(t("restore_default_apis_success"));
|
||||
|
||||
void this.plugin.saveSettings();
|
||||
await this.plugin.saveSettings();
|
||||
this.display(this.container);
|
||||
});
|
||||
});
|
||||
|
|
@ -108,28 +109,32 @@ export class ApiSettingsSection {
|
|||
container.empty();
|
||||
|
||||
// 初始化 API 拖拽排序
|
||||
this.apiDragSort = new DragSort<WallpaperApiConfig>({
|
||||
const apiDragSort = new DragSort<WallpaperApiConfig>({
|
||||
container,
|
||||
items: this.plugin.settings.wallpaperApis,
|
||||
getItemId: (api) => api.id,
|
||||
itemClass: "dtb-draggable",
|
||||
idDataAttribute: "apiId",
|
||||
reorderLabels: { up: t("move_item_up"), down: t("move_item_down") },
|
||||
onReorder: async (reorderedApis) => {
|
||||
this.plugin.settings.wallpaperApis = reorderedApis;
|
||||
await this.plugin.saveSettings();
|
||||
this.displayWallpaperApis();
|
||||
},
|
||||
});
|
||||
this.apiDragSort = apiDragSort;
|
||||
|
||||
// API 列表
|
||||
this.plugin.settings.wallpaperApis.forEach((apiConfig: WallpaperApiConfig, index: number) => {
|
||||
const apiInstance = apiManager.getApiById(apiConfig.id);
|
||||
if (!apiInstance) {
|
||||
logger.warn(`API instance not found for ${apiConfig.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = new Setting(container).setName(apiConfig.name).setDesc(apiInstance.getDescription());
|
||||
const description =
|
||||
apiInstance?.getDescription() ??
|
||||
apiConfig.description ??
|
||||
apiRegistry.getDefaultDescription(apiConfig.type) ??
|
||||
t("notice_api_secret_unavailable");
|
||||
const setting = new Setting(container).setName(apiConfig.name).setDesc(description);
|
||||
|
||||
// 在设置项的控件区域直接添加类型标签
|
||||
setting.controlEl.createSpan({ text: apiConfig.type ?? "Unknown", cls: "dtb-badge" });
|
||||
|
|
@ -141,7 +146,11 @@ export class ApiSettingsSection {
|
|||
const statusDot = statusIndicator.createDiv("dtb-api-status-dot");
|
||||
const statusText = statusIndicator.createSpan();
|
||||
// 根据API的启用状态设置初始状态
|
||||
if (apiInstance.getEnabled()) {
|
||||
if (!apiInstance) {
|
||||
statusDot.addClass("error");
|
||||
statusText.textContent = t("status_error");
|
||||
statusText.title = t("notice_api_secret_unavailable");
|
||||
} else if (apiInstance.getEnabled()) {
|
||||
statusDot.addClass("enabled");
|
||||
statusText.textContent = t("status_enabled");
|
||||
} else {
|
||||
|
|
@ -153,7 +162,7 @@ export class ApiSettingsSection {
|
|||
let toggleComponent: { setValue: (value: boolean) => void; getValue: () => boolean } | null = null;
|
||||
setting.addToggle((toggle) => {
|
||||
toggleComponent = toggle; // 保存 toggle 引用
|
||||
const toggleEl = toggle.setValue(apiInstance.getEnabled());
|
||||
const toggleEl = toggle.setValue(apiInstance?.getEnabled() ?? false).setDisabled(!apiInstance);
|
||||
|
||||
// 使用智能API管理方法
|
||||
toggleEl.onChange(async (value) => {
|
||||
|
|
@ -243,8 +252,9 @@ export class ApiSettingsSection {
|
|||
button
|
||||
.setButtonText(t("button_add"))
|
||||
.setTooltip(t("add_api_bg_tooltip"))
|
||||
.setDisabled(!apiInstance)
|
||||
.onClick(async () => {
|
||||
await this.fetchWallpaperFromApi(apiInstance);
|
||||
if (apiInstance) await this.fetchWallpaperFromApi(apiInstance);
|
||||
})
|
||||
)
|
||||
.addButton((button) =>
|
||||
|
|
@ -265,15 +275,10 @@ export class ApiSettingsSection {
|
|||
})
|
||||
);
|
||||
|
||||
// 设置拖拽属性
|
||||
setting.settingEl.addClass("dtb-draggable");
|
||||
setting.settingEl.dataset.apiId = apiConfig.id;
|
||||
|
||||
// 添加通用条目样式类
|
||||
setting.settingEl.addClass("dtb-button-container"); // 按钮样式
|
||||
|
||||
// 启用拖拽功能
|
||||
this.apiDragSort?.enableDragForElement(setting.settingEl, apiConfig);
|
||||
apiDragSort.enableDragForElement(setting.settingEl, apiConfig, setting.controlEl);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -288,12 +293,11 @@ export class ApiSettingsSection {
|
|||
params: {},
|
||||
};
|
||||
|
||||
const modal = new WallpaperApiEditorModal(this.plugin.app, emptyConfig, (apiConfig) => {
|
||||
// 创建新的API实例
|
||||
void apiManager.createApi(apiConfig, this.plugin.settings.enabled);
|
||||
const modal = new WallpaperApiEditorModal(this.plugin, emptyConfig, async (apiConfig) => {
|
||||
// 添加到插件设置中
|
||||
this.plugin.settings.wallpaperApis.push(apiConfig);
|
||||
void this.plugin.saveSettings();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.createWallpaperApi(apiConfig, this.plugin.settings.enabled);
|
||||
// 这里仅需刷新 api 列表
|
||||
this.displayWallpaperApis();
|
||||
});
|
||||
|
|
@ -303,12 +307,11 @@ export class ApiSettingsSection {
|
|||
|
||||
// 显示编辑壁纸API的模态窗口
|
||||
private showEditWallpaperApiModal(apiConfig: WallpaperApiConfig, index: number) {
|
||||
const modal = new WallpaperApiEditorModal(this.plugin.app, apiConfig, (updatedConfig) => {
|
||||
// 有可能api类型也修改了,干脆重新创建API实例覆盖原来的
|
||||
void apiManager.createApi(updatedConfig, this.plugin.settings.enabled);
|
||||
|
||||
const modal = new WallpaperApiEditorModal(this.plugin, apiConfig, async (updatedConfig) => {
|
||||
this.plugin.settings.wallpaperApis[index] = updatedConfig;
|
||||
void this.plugin.saveSettings();
|
||||
await this.plugin.saveSettings();
|
||||
// 有可能api类型也修改了,重新创建API实例覆盖原来的
|
||||
await this.plugin.createWallpaperApi(updatedConfig, this.plugin.settings.enabled);
|
||||
// 这里仅需刷新 api 列表
|
||||
this.displayWallpaperApis();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,27 +106,29 @@ export class BgManagementSection {
|
|||
container.empty();
|
||||
|
||||
// 初始化背景拖拽排序
|
||||
this.backgroundDragSort = new DragSort<BackgroundItem>({
|
||||
const backgroundDragSort = new DragSort<BackgroundItem>({
|
||||
container,
|
||||
items: this.plugin.settings.backgrounds,
|
||||
getItemId: (bg) => bg.id,
|
||||
itemClass: "dtb-draggable",
|
||||
idDataAttribute: "bgId",
|
||||
reorderLabels: { up: t("move_item_up"), down: t("move_item_down") },
|
||||
onReorder: async (reorderedBackgrounds) => {
|
||||
const activeBackgroundId = this.plugin.background?.id;
|
||||
this.plugin.settings.backgrounds = reorderedBackgrounds;
|
||||
if (activeBackgroundId) {
|
||||
const activeIndex = reorderedBackgrounds.findIndex(
|
||||
(background) => background.id === activeBackgroundId
|
||||
);
|
||||
if (activeIndex >= 0) this.plugin.settings.currentIndex = activeIndex;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
// 这里仅需刷新背景列表和时间规则列表
|
||||
this.displayBackgrounds();
|
||||
this.onChanged?.();
|
||||
},
|
||||
});
|
||||
this.backgroundDragSort = backgroundDragSort;
|
||||
this.plugin.settings.backgrounds.forEach((bg: BackgroundItem, index: number) => {
|
||||
const bgEl = container.createDiv("dtb-item dtb-draggable");
|
||||
|
||||
// 添加拖拽相关属性
|
||||
bgEl.draggable = true;
|
||||
bgEl.dataset.bgId = bg.id;
|
||||
bgEl.dataset.index = index.toString();
|
||||
const bgEl = container.createDiv("dtb-item");
|
||||
|
||||
// 添加拖拽手柄
|
||||
const dragHandle = bgEl.createDiv("dtb-drag-handle");
|
||||
|
|
@ -185,8 +187,7 @@ export class BgManagementSection {
|
|||
this.onChanged?.();
|
||||
};
|
||||
|
||||
// 启用拖拽功能
|
||||
this.backgroundDragSort?.enableDragForElement(bgEl, bg);
|
||||
backgroundDragSort.enableDragForElement(bgEl, bg, actions);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { t } from "../../i18n";
|
|||
import { ConfirmModal, TimeRuleModal } from "../../modals";
|
||||
import type DynamicThemeBackgroundPlugin from "../../plugin";
|
||||
import type { DTBSettings, TimeRule } from "../../types";
|
||||
import { DragSort, addDropdownTooltip, addEnhancedDropdownTooltip, generateId } from "../../utils";
|
||||
import {
|
||||
DragSort,
|
||||
addDropdownTooltip,
|
||||
addEnhancedDropdownTooltip,
|
||||
generateId,
|
||||
} from "../../utils";
|
||||
|
||||
export class ModeSettingsSection {
|
||||
private plugin: DynamicThemeBackgroundPlugin;
|
||||
|
|
@ -143,18 +148,19 @@ export class ModeSettingsSection {
|
|||
container.empty();
|
||||
|
||||
// 初始化时间规则拖拽排序
|
||||
this.timeRuleDragSort = new DragSort<TimeRule>({
|
||||
const timeRuleDragSort = new DragSort<TimeRule>({
|
||||
container,
|
||||
items: this.plugin.settings.timeRules,
|
||||
getItemId: (rule) => rule.id,
|
||||
itemClass: "dtb-draggable",
|
||||
idDataAttribute: "ruleId",
|
||||
reorderLabels: { up: t("move_item_up"), down: t("move_item_down") },
|
||||
onReorder: async (reorderedRules) => {
|
||||
this.plugin.settings.timeRules = reorderedRules;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.startBackgroundManager();
|
||||
this.displayTimeRules();
|
||||
},
|
||||
});
|
||||
this.timeRuleDragSort = timeRuleDragSort;
|
||||
|
||||
// 获取当前激活的时间规则
|
||||
const activeRule = this.plugin.getCurrentTimeRule();
|
||||
|
|
@ -217,15 +223,10 @@ export class ModeSettingsSection {
|
|||
})
|
||||
);
|
||||
|
||||
// 设置拖拽属性
|
||||
setting.settingEl.addClass("dtb-draggable");
|
||||
setting.settingEl.dataset.ruleId = rule.id;
|
||||
|
||||
// 添加通用条目样式类
|
||||
setting.settingEl.addClass("dtb-button-container"); // 按钮样式
|
||||
|
||||
// 启用拖拽功能
|
||||
this.timeRuleDragSort?.enableDragForElement(setting.settingEl, rule);
|
||||
timeRuleDragSort.enableDragForElement(setting.settingEl, rule, setting.controlEl);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
* 协调各设置区块的展示和刷新
|
||||
*/
|
||||
import { App, PluginSettingTab } from "obsidian";
|
||||
import type { Setting, SettingDefinitionItem } from "obsidian";
|
||||
|
||||
import { displayImperativeSettings, refreshDualSettings } from "../core/obsidian-compat";
|
||||
import { getDefaultSettings } from "../default-settings";
|
||||
import { t } from "../i18n";
|
||||
import type DynamicThemeBackgroundPlugin from "../plugin";
|
||||
|
|
@ -18,6 +20,7 @@ export class DTBSettingTab extends PluginSettingTab {
|
|||
|
||||
private componentId: string;
|
||||
private active!: boolean;
|
||||
private declarativeActive = false;
|
||||
|
||||
// 设置区块
|
||||
private basicSection?: BasicSettingsSection;
|
||||
|
|
@ -46,11 +49,46 @@ export class DTBSettingTab extends PluginSettingTab {
|
|||
hide(): void {
|
||||
this.cleanup();
|
||||
this.active = false;
|
||||
this.declarativeActive = false;
|
||||
this.plugin.settingTabs.delete(this.componentId);
|
||||
}
|
||||
|
||||
getSettingDefinitions(): SettingDefinitionItem[] {
|
||||
return [
|
||||
{
|
||||
name: t("settings_title"),
|
||||
searchable: false,
|
||||
render: (setting) => {
|
||||
this.activateSurface(true);
|
||||
setting.settingEl.empty();
|
||||
this.displayHeader(setting.settingEl);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
name: t("basic_settings_title"),
|
||||
items: [this.basicSettingsDefinition()],
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
name: t("mode_settings_title"),
|
||||
items: [this.modeSettingsDefinition()],
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
name: t("bg_management_title"),
|
||||
items: [this.backgroundSettingsDefinition()],
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
name: t("wallpaper_api_management_title"),
|
||||
items: [this.apiSettingsDefinition()],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
display(): void {
|
||||
this.active = true;
|
||||
this.activateSurface(false);
|
||||
this.cleanup();
|
||||
|
||||
const { containerEl } = this;
|
||||
|
|
@ -87,6 +125,14 @@ export class DTBSettingTab extends PluginSettingTab {
|
|||
this.apiSection.display(apiEl);
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
if (this.declarativeActive) {
|
||||
refreshDualSettings(this);
|
||||
} else {
|
||||
displayImperativeSettings(this);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 针对性刷新(plugin.ts 调用)
|
||||
// ============================================================================
|
||||
|
|
@ -107,6 +153,85 @@ export class DTBSettingTab extends PluginSettingTab {
|
|||
// 内部方法
|
||||
// ============================================================================
|
||||
|
||||
private activateSurface(declarative: boolean): void {
|
||||
this.active = true;
|
||||
this.declarativeActive = declarative;
|
||||
this.plugin.settingTabs.set(this.componentId, this);
|
||||
}
|
||||
|
||||
private basicSettingsDefinition(): SettingDefinitionItem {
|
||||
return {
|
||||
name: t("basic_settings_title"),
|
||||
searchable: false,
|
||||
render: (setting: Setting) => {
|
||||
setting.settingEl.empty();
|
||||
const section = new BasicSettingsSection(this.plugin, this.defaultSettings);
|
||||
this.basicSection = section;
|
||||
section.display(setting.settingEl);
|
||||
return () => {
|
||||
section.cleanup();
|
||||
if (this.basicSection === section) this.basicSection = undefined;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private modeSettingsDefinition(): SettingDefinitionItem {
|
||||
return {
|
||||
name: t("mode_settings_title"),
|
||||
searchable: false,
|
||||
render: (setting: Setting) => {
|
||||
setting.settingEl.empty();
|
||||
const section = new ModeSettingsSection(this.plugin, this.defaultSettings);
|
||||
this.modeSection = section;
|
||||
section.display(setting.settingEl);
|
||||
return () => {
|
||||
section.cleanup();
|
||||
if (this.modeSection === section) this.modeSection = undefined;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private backgroundSettingsDefinition(): SettingDefinitionItem {
|
||||
return {
|
||||
name: t("bg_management_title"),
|
||||
searchable: false,
|
||||
render: (setting: Setting) => {
|
||||
setting.settingEl.empty();
|
||||
const section = new BgManagementSection(this.plugin, this.defaultSettings, {
|
||||
onChanged: () => this.modeSection?.displayTimeRules(),
|
||||
});
|
||||
this.bgSection = section;
|
||||
section.display(setting.settingEl);
|
||||
return () => {
|
||||
section.cleanup();
|
||||
if (this.bgSection === section) this.bgSection = undefined;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private apiSettingsDefinition(): SettingDefinitionItem {
|
||||
return {
|
||||
name: t("wallpaper_api_management_title"),
|
||||
searchable: false,
|
||||
render: (setting: Setting) => {
|
||||
setting.settingEl.empty();
|
||||
const section = new ApiSettingsSection(this.plugin, this.defaultSettings, {
|
||||
onBackgroundsChanged: () => this.bgSection?.displayBackgrounds(),
|
||||
onTimeRulesChanged: () => this.modeSection?.displayTimeRules(),
|
||||
});
|
||||
this.apiSection = section;
|
||||
section.display(setting.settingEl);
|
||||
return () => {
|
||||
section.cleanup();
|
||||
if (this.apiSection === section) this.apiSection = undefined;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private displayHeader(containerEl: HTMLElement) {
|
||||
const headerContainer = containerEl.createDiv("dtb-section-header");
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import type { WallpaperApiConfig } from "./wallpaper-apis";
|
||||
|
||||
export interface DTBSettings {
|
||||
credentialStorageVersion: number;
|
||||
enabled: boolean;
|
||||
statusBarEnabled: boolean; // 是否激活状态栏
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
import { logger } from "../core/logger";
|
||||
|
||||
const DRAGGABLE_CLASS = "dtb-draggable";
|
||||
|
||||
/**
|
||||
* 拖拽排序配置接口
|
||||
*/
|
||||
|
|
@ -15,62 +17,40 @@ export interface DragSortConfig<T> {
|
|||
items: T[];
|
||||
/** 获取项目唯一ID的函数 */
|
||||
getItemId: (item: T) => string;
|
||||
/** 拖拽项目类名 */
|
||||
itemClass?: string;
|
||||
/** ID数据属性名 */
|
||||
idDataAttribute?: string;
|
||||
/** Accessible labels for optional keyboard/touch reorder buttons. */
|
||||
reorderLabels?: { up: string; down: string };
|
||||
/** 排序完成后的回调函数 */
|
||||
onReorder: (items: T[]) => Promise<void> | void;
|
||||
/** 拖拽开始时的回调函数 */
|
||||
onDragStart?: (item: T) => void;
|
||||
/** 拖拽结束时的回调函数 */
|
||||
onDragEnd?: (item: T) => void;
|
||||
}
|
||||
|
||||
export type ReorderDirection = -1 | 1;
|
||||
|
||||
/**
|
||||
* 通用拖拽排序工具类
|
||||
*/
|
||||
export class DragSort<T> {
|
||||
private config: DragSortConfig<T>;
|
||||
private dragHandles: WeakMap<HTMLElement, () => void> = new WeakMap();
|
||||
private reorderPending = false;
|
||||
|
||||
constructor(config: DragSortConfig<T>) {
|
||||
this.config = {
|
||||
itemClass: "dtb-draggable",
|
||||
idDataAttribute: "itemId",
|
||||
...config,
|
||||
};
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定元素添加拖拽功能
|
||||
*/
|
||||
public enableDragForElement(element: HTMLElement, item: T): void {
|
||||
public enableDragForElement(element: HTMLElement, item: T, reorderControls?: HTMLElement): void {
|
||||
const itemId = this.config.getItemId(item);
|
||||
|
||||
// 设置拖拽属性
|
||||
element.draggable = true;
|
||||
if (this.config.itemClass) {
|
||||
element.classList.add(this.config.itemClass);
|
||||
}
|
||||
if (this.config.idDataAttribute) {
|
||||
element.dataset[this.config.idDataAttribute] = itemId;
|
||||
}
|
||||
element.classList.add(DRAGGABLE_CLASS);
|
||||
element.dataset.itemId = itemId;
|
||||
|
||||
// 添加拖拽事件监听器
|
||||
this.addDragListeners(element, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为容器内的所有项目启用拖拽功能
|
||||
*/
|
||||
public enableDragForAllItems(): void {
|
||||
const elements = this.config.container.querySelectorAll(`.${this.config.itemClass}`);
|
||||
elements.forEach((element, index) => {
|
||||
if (index < this.config.items.length) {
|
||||
this.enableDragForElement(element as HTMLElement, this.config.items[index]);
|
||||
}
|
||||
});
|
||||
if (reorderControls) this.addReorderControls(reorderControls, item);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,9 +58,7 @@ export class DragSort<T> {
|
|||
*/
|
||||
public disableDragForElement(element: HTMLElement): void {
|
||||
element.draggable = false;
|
||||
if (this.config.itemClass) {
|
||||
element.classList.remove(this.config.itemClass);
|
||||
}
|
||||
element.classList.remove(DRAGGABLE_CLASS);
|
||||
|
||||
// 移除事件监听器(WeakMap 自动清理引用,无需手动 delete)
|
||||
const cleanup = this.dragHandles.get(element);
|
||||
|
|
@ -93,17 +71,29 @@ export class DragSort<T> {
|
|||
* 禁用所有拖拽功能
|
||||
*/
|
||||
public disableAllDrag(): void {
|
||||
const elements = this.config.container.querySelectorAll(`.${this.config.itemClass}`);
|
||||
const elements = this.config.container.querySelectorAll(`.${DRAGGABLE_CLASS}`);
|
||||
elements.forEach((element) => {
|
||||
this.disableDragForElement(element as HTMLElement);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
public updateConfig(newConfig: Partial<DragSortConfig<T>>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
/** Return whether an item can move one position in the requested direction. */
|
||||
public canMove(item: T, direction: ReorderDirection): boolean {
|
||||
if (this.reorderPending) return false;
|
||||
const index = this.findItemIndex(item);
|
||||
const targetIndex = index + direction;
|
||||
return index >= 0 && targetIndex >= 0 && targetIndex < this.config.items.length;
|
||||
}
|
||||
|
||||
/** Move an item one position without requiring pointer drag support. */
|
||||
public async moveItem(item: T, direction: ReorderDirection): Promise<boolean> {
|
||||
if (!this.canMove(item, direction)) return false;
|
||||
|
||||
const items = [...this.config.items];
|
||||
const currentIndex = this.findItemIndex(item);
|
||||
const [movedItem] = items.splice(currentIndex, 1);
|
||||
items.splice(currentIndex + direction, 0, movedItem);
|
||||
return this.commitReorder(items);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -116,17 +106,15 @@ export class DragSort<T> {
|
|||
e.dataTransfer.setData("text/plain", this.config.getItemId(item));
|
||||
element.classList.add("dtb-dragging");
|
||||
}
|
||||
this.config.onDragStart?.(item);
|
||||
};
|
||||
|
||||
const dragEndHandler = () => {
|
||||
element.classList.remove("dtb-dragging");
|
||||
// 移除所有拖拽相关的样式
|
||||
const allItems = this.config.container.querySelectorAll(`.${this.config.itemClass}`);
|
||||
const allItems = this.config.container.querySelectorAll(`.${DRAGGABLE_CLASS}`);
|
||||
allItems?.forEach((item) => {
|
||||
item.classList.remove("dtb-drag-over", "dtb-drag-over-top", "dtb-drag-over-bottom");
|
||||
});
|
||||
this.config.onDragEnd?.(item);
|
||||
};
|
||||
|
||||
const dragOverHandler = (e: DragEvent) => {
|
||||
|
|
@ -162,7 +150,7 @@ export class DragSort<T> {
|
|||
e.preventDefault();
|
||||
|
||||
const draggedId = e.dataTransfer?.getData("text/plain");
|
||||
const targetId = this.config.idDataAttribute ? element.dataset[this.config.idDataAttribute] : undefined;
|
||||
const targetId = element.dataset.itemId;
|
||||
|
||||
if (!draggedId || !targetId || draggedId === targetId) {
|
||||
return;
|
||||
|
|
@ -231,10 +219,47 @@ export class DragSort<T> {
|
|||
// 插入到新位置
|
||||
items.splice(newTargetIndex, 0, draggedItem);
|
||||
|
||||
// 更新配置中的项目列表
|
||||
this.config.items.splice(0, this.config.items.length, ...items);
|
||||
await this.commitReorder(items);
|
||||
}
|
||||
|
||||
// 调用重排序回调
|
||||
await this.config.onReorder(items);
|
||||
private findItemIndex(item: T): number {
|
||||
const itemId = this.config.getItemId(item);
|
||||
return this.config.items.findIndex((candidate) => this.config.getItemId(candidate) === itemId);
|
||||
}
|
||||
|
||||
private addReorderControls(container: HTMLElement, item: T): void {
|
||||
const labels = this.config.reorderLabels;
|
||||
if (!labels) return;
|
||||
for (const direction of [-1, 1] as const) {
|
||||
const label = direction < 0 ? labels.up : labels.down;
|
||||
const button = container.createEl("button", {
|
||||
text: direction < 0 ? "↑" : "↓",
|
||||
cls: "dtb-reorder-button",
|
||||
attr: { "aria-label": label },
|
||||
});
|
||||
button.disabled = !this.canMove(item, direction);
|
||||
button.onclick = () => {
|
||||
void this.moveItem(item, direction).catch((error) =>
|
||||
logger.error("Reorder", error)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async commitReorder(items: T[]): Promise<boolean> {
|
||||
if (this.reorderPending) return false;
|
||||
|
||||
const previousItems = [...this.config.items];
|
||||
this.reorderPending = true;
|
||||
this.config.items.splice(0, this.config.items.length, ...items);
|
||||
try {
|
||||
await this.config.onReorder([...items]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.config.items.splice(0, this.config.items.length, ...previousItems);
|
||||
throw error;
|
||||
} finally {
|
||||
this.reorderPending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ export interface WallpaperApiParams {
|
|||
[key: string]: ApiValueType;
|
||||
}
|
||||
|
||||
/**
|
||||
* SecretStorage IDs for values that are hydrated into a short-lived runtime config.
|
||||
* The referenced secret values must never be persisted in this object.
|
||||
*/
|
||||
export interface WallpaperApiSecretRefs {
|
||||
params?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 壁纸图片接口
|
||||
*/
|
||||
|
|
@ -103,8 +112,9 @@ export interface WallpaperApiConfig {
|
|||
description?: string; // API实例描述
|
||||
baseUrl: string; // API基础域名或服务地址 (如: https://wallhaven.cc/api/v1)
|
||||
endpoints?: WallpaperApiEndpoints; // 具体的端点配置,如果不提供则使用默认端点
|
||||
headers?: Record<string, string>; // 请求头
|
||||
headers?: Record<string, string>; // 仅用于迁移旧数据和运行时请求;不得保存到 data.json
|
||||
params: WallpaperApiParams;
|
||||
secretRefs?: WallpaperApiSecretRefs; // 持久化的 SecretStorage 引用
|
||||
|
||||
// 自定义设置
|
||||
customSettings?: {
|
||||
|
|
|
|||
88
styles.css
88
styles.css
|
|
@ -169,8 +169,8 @@
|
|||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start !important;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 1rem !important;
|
||||
box-shadow: var(--dtb-shadow-base);
|
||||
}
|
||||
|
|
@ -334,6 +334,10 @@
|
|||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.dtb-secret-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 通用按钮容器样式 */
|
||||
.dtb-flex-container-end {
|
||||
|
|
@ -362,13 +366,6 @@
|
|||
|
||||
/* 响应式设计 (Responsive Design) */
|
||||
@media (max-width: 600px) {
|
||||
.dtb-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.dtb-flex-container,
|
||||
.dtb-flex-container-center,
|
||||
.dtb-flex-container-spaced {
|
||||
|
|
@ -595,6 +592,11 @@
|
|||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.dtb-reorder-button {
|
||||
flex: 0 0 auto;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
/* 通用操作按钮 */
|
||||
.dtb-button,
|
||||
.dtb-button-container button {
|
||||
|
|
@ -1058,6 +1060,70 @@
|
|||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
:has(> .dtb-hint) > .setting-item,
|
||||
.dtb-large-button-container .setting-item,
|
||||
.dtb-item,
|
||||
.dtb-section-container .setting-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem !important;
|
||||
padding: 0.8rem !important;
|
||||
}
|
||||
|
||||
:has(> .dtb-hint) > .setting-item .setting-item-info,
|
||||
.dtb-large-button-container .setting-item .setting-item-info,
|
||||
.dtb-section-container .setting-item .setting-item-info {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:has(> .dtb-hint) > .setting-item .setting-item-control,
|
||||
.dtb-large-button-container .setting-item .setting-item-control,
|
||||
.dtb-section-container .setting-item .setting-item-control {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dtb-bg-content {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dtb-bg-content > .dtb-button-container {
|
||||
flex: 1 0 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dtb-bg-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dtb-section-header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dtb-links {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dtb-bg-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 炫酷状态栏图标样式 ===== */
|
||||
.dtb-status-bar {
|
||||
display: flex;
|
||||
|
|
@ -1136,7 +1202,9 @@
|
|||
.dtb-large-button,
|
||||
.dtb-large-button-container button,
|
||||
.dtb-link,
|
||||
.dtb-remove-button {
|
||||
.dtb-remove-button,
|
||||
.dtb-button-container .dtb-reorder-button,
|
||||
.dtb-reorder-button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
|
|
|||
220
tests/credential-storage.test.ts
Normal file
220
tests/credential-storage.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CURRENT_CREDENTIAL_STORAGE_VERSION,
|
||||
CredentialMigrationError,
|
||||
MissingSecretReferenceError,
|
||||
assertSettingsCredentialsAreReferences,
|
||||
hydrateWallpaperApiConfig,
|
||||
migrateSettingsCredentials,
|
||||
} from "../src/core/credential-storage";
|
||||
import type { SecretStore, SensitiveParamResolver } from "../src/core/credential-storage";
|
||||
import type { DTBSettings } from "../src/types";
|
||||
import { WallpaperApiType } from "../src/wallpaper-apis/core/types";
|
||||
|
||||
class FakeSecretStore implements SecretStore {
|
||||
readonly values = new Map<string, string>();
|
||||
setCalls = 0;
|
||||
failOnSetCall?: number;
|
||||
|
||||
getSecret(id: string): string | null {
|
||||
return this.values.get(id) ?? null;
|
||||
}
|
||||
|
||||
listSecrets(): string[] {
|
||||
return Array.from(this.values.keys());
|
||||
}
|
||||
|
||||
setSecret(id: string, secret: string): void {
|
||||
this.setCalls += 1;
|
||||
if (this.setCalls === this.failOnSetCall) {
|
||||
throw new Error("simulated SecretStorage failure");
|
||||
}
|
||||
this.values.set(id, secret);
|
||||
}
|
||||
}
|
||||
|
||||
const sensitiveParams: SensitiveParamResolver = (config) =>
|
||||
config.type === WallpaperApiType.Unsplash ? ["client_id"] : [];
|
||||
|
||||
function settingsFixture(): DTBSettings {
|
||||
return {
|
||||
credentialStorageVersion: 0,
|
||||
enabled: true,
|
||||
statusBarEnabled: true,
|
||||
blurDepth: 0,
|
||||
brightness4Bg: 0.9,
|
||||
saturate4Bg: 1,
|
||||
bgColorLight: "#fff",
|
||||
bgColorOpacityLight: 0.3,
|
||||
bgColorDark: "#000",
|
||||
bgColorOpacityDark: 0.4,
|
||||
bgSize: "intelligent",
|
||||
mode: "manual",
|
||||
timeRules: [],
|
||||
intervalMinutes: 60,
|
||||
localBackgroundFolder: "",
|
||||
backgrounds: [],
|
||||
currentIndex: 0,
|
||||
enableRandomWallpaper: true,
|
||||
wallpaperApis: [
|
||||
{
|
||||
id: "My Unsplash/API",
|
||||
type: WallpaperApiType.Unsplash,
|
||||
enabled: false,
|
||||
name: "Unsplash",
|
||||
baseUrl: "https://api.unsplash.com",
|
||||
params: { client_id: "provider-secret", query: "mountains" },
|
||||
headers: {
|
||||
Authorization: "Bearer header-secret",
|
||||
"X-Trace": "private-header-value",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
void test("credential migration replaces password parameters and every custom header with references", () => {
|
||||
const source = settingsFixture();
|
||||
const store = new FakeSecretStore();
|
||||
|
||||
const result = migrateSettingsCredentials(source, store, sensitiveParams);
|
||||
const migrated = result.settings.wallpaperApis[0];
|
||||
const serialized = JSON.stringify(result.settings);
|
||||
|
||||
assert.equal(result.migrated, true);
|
||||
assert.equal(result.settings.credentialStorageVersion, CURRENT_CREDENTIAL_STORAGE_VERSION);
|
||||
assert.deepEqual(migrated.params, { query: "mountains" });
|
||||
assert.equal(migrated.headers, undefined);
|
||||
assert.ok(migrated.secretRefs?.params?.client_id);
|
||||
assert.ok(migrated.secretRefs?.headers?.Authorization);
|
||||
assert.ok(migrated.secretRefs?.headers?.["X-Trace"]);
|
||||
for (const id of store.listSecrets()) {
|
||||
assert.match(id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
|
||||
}
|
||||
assert.doesNotMatch(serialized, /provider-secret|header-secret|private-header-value/u);
|
||||
assert.equal(source.wallpaperApis[0].params.client_id, "provider-secret");
|
||||
assert.equal(source.wallpaperApis[0].headers?.Authorization, "Bearer header-secret");
|
||||
assertSettingsCredentialsAreReferences(result.settings, sensitiveParams);
|
||||
});
|
||||
|
||||
void test("credential migration is idempotent and reuses an occupied ID only for the same value", () => {
|
||||
const store = new FakeSecretStore();
|
||||
store.values.set("dynamic-theme-background-my-unsplash-api-param-client-id", "another-user-secret");
|
||||
|
||||
const first = migrateSettingsCredentials(settingsFixture(), store, sensitiveParams);
|
||||
const firstRef = first.settings.wallpaperApis[0].secretRefs?.params?.client_id;
|
||||
const callsAfterFirst = store.setCalls;
|
||||
const second = migrateSettingsCredentials(first.settings, store, sensitiveParams);
|
||||
|
||||
assert.notEqual(firstRef, "dynamic-theme-background-my-unsplash-api-param-client-id");
|
||||
assert.match(firstRef ?? "", /-2$/u);
|
||||
assert.equal(store.getSecret(firstRef ?? ""), "provider-secret");
|
||||
assert.equal(second.migrated, false);
|
||||
assert.deepEqual(second.settings, first.settings);
|
||||
assert.equal(store.setCalls, callsAfterFirst);
|
||||
});
|
||||
|
||||
void test("a failed SecretStorage write leaves the persisted source fully recoverable", () => {
|
||||
const source = settingsFixture();
|
||||
const before = JSON.stringify(source);
|
||||
const store = new FakeSecretStore();
|
||||
store.failOnSetCall = 2;
|
||||
|
||||
assert.throws(
|
||||
() => migrateSettingsCredentials(source, store, sensitiveParams),
|
||||
(error: unknown) => error instanceof CredentialMigrationError
|
||||
);
|
||||
assert.equal(JSON.stringify(source), before);
|
||||
assert.equal(source.credentialStorageVersion, 0);
|
||||
});
|
||||
|
||||
void test("a partial write can be retried without creating duplicate secret IDs", () => {
|
||||
const source = settingsFixture();
|
||||
const store = new FakeSecretStore();
|
||||
store.failOnSetCall = 2;
|
||||
|
||||
assert.throws(() => migrateSettingsCredentials(source, store, sensitiveParams), CredentialMigrationError);
|
||||
store.failOnSetCall = undefined;
|
||||
const retried = migrateSettingsCredentials(source, store, sensitiveParams);
|
||||
|
||||
assert.equal(
|
||||
retried.settings.wallpaperApis[0].secretRefs?.params?.client_id,
|
||||
"dynamic-theme-background-my-unsplash-api-param-client-id"
|
||||
);
|
||||
assert.equal(store.listSecrets().some((id) => /param-client-id-2$/u.test(id)), false);
|
||||
assertSettingsCredentialsAreReferences(retried.settings, sensitiveParams);
|
||||
});
|
||||
|
||||
void test("storage failures and reference conflicts reject generically without exposing credential values", () => {
|
||||
const source = settingsFixture();
|
||||
const unavailableStore: SecretStore = {
|
||||
getSecret: () => {
|
||||
throw new Error("provider-secret");
|
||||
},
|
||||
setSecret: () => undefined,
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => migrateSettingsCredentials(source, unavailableStore, sensitiveParams),
|
||||
(error: unknown) =>
|
||||
error instanceof CredentialMigrationError && !error.message.includes("provider-secret")
|
||||
);
|
||||
|
||||
const conflictStore = new FakeSecretStore();
|
||||
conflictStore.values.set("existing-reference", "different-value");
|
||||
source.wallpaperApis[0].secretRefs = { params: { client_id: "existing-reference" } };
|
||||
const before = JSON.stringify(source);
|
||||
assert.throws(
|
||||
() => migrateSettingsCredentials(source, conflictStore, sensitiveParams),
|
||||
(error: unknown) =>
|
||||
error instanceof CredentialMigrationError && !error.message.includes("provider-secret")
|
||||
);
|
||||
assert.equal(JSON.stringify(source), before);
|
||||
});
|
||||
|
||||
void test("already-sanitized settings do not read SecretStorage during load", () => {
|
||||
const initialStore = new FakeSecretStore();
|
||||
const sanitized = migrateSettingsCredentials(settingsFixture(), initialStore, sensitiveParams).settings;
|
||||
const noEnumerationStore: SecretStore = {
|
||||
getSecret: () => {
|
||||
throw new Error("getSecret should not run during an idempotent migration");
|
||||
},
|
||||
setSecret: () => {
|
||||
throw new Error("setSecret should not run during an idempotent migration");
|
||||
},
|
||||
};
|
||||
|
||||
const result = migrateSettingsCredentials(sanitized, noEnumerationStore, sensitiveParams);
|
||||
|
||||
assert.equal(result.migrated, false);
|
||||
assert.deepEqual(result.settings, sanitized);
|
||||
});
|
||||
|
||||
void test("runtime hydration returns an isolated config and fails closed for missing references", () => {
|
||||
const store = new FakeSecretStore();
|
||||
const migrated = migrateSettingsCredentials(settingsFixture(), store, sensitiveParams).settings.wallpaperApis[0];
|
||||
const hydrated = hydrateWallpaperApiConfig(migrated, store);
|
||||
|
||||
assert.equal(hydrated.params.client_id, "provider-secret");
|
||||
assert.equal(hydrated.headers?.Authorization, "Bearer header-secret");
|
||||
assert.equal(hydrated.headers?.["X-Trace"], "private-header-value");
|
||||
assert.notEqual(hydrated, migrated);
|
||||
assert.equal(migrated.params.client_id, undefined);
|
||||
assert.equal(migrated.headers, undefined);
|
||||
|
||||
const missingRef = migrated.secretRefs?.params?.client_id;
|
||||
assert.ok(missingRef);
|
||||
store.values.delete(missingRef);
|
||||
assert.throws(
|
||||
() => hydrateWallpaperApiConfig(migrated, store),
|
||||
(error: unknown) => error instanceof MissingSecretReferenceError
|
||||
);
|
||||
});
|
||||
|
||||
void test("the persistence guard rejects both legacy password fields and custom headers", () => {
|
||||
const source = settingsFixture();
|
||||
|
||||
assert.throws(() => assertSettingsCredentialsAreReferences(source, sensitiveParams), /plaintext credentials/u);
|
||||
});
|
||||
70
tests/drag-sort.test.ts
Normal file
70
tests/drag-sort.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DragSort } from "../src/utils/drag-sort";
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function createSort(items: Item[], onReorder: (reordered: Item[]) => Promise<void> | void): DragSort<Item> {
|
||||
return new DragSort<Item>({
|
||||
container: {} as HTMLElement,
|
||||
items,
|
||||
getItemId: (item) => item.id,
|
||||
onReorder,
|
||||
});
|
||||
}
|
||||
|
||||
void test("accessible reorder moves one position and reports boundaries", async () => {
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
const observed: string[][] = [];
|
||||
const sort = createSort(items, (reordered) => {
|
||||
observed.push(reordered.map((item) => item.id));
|
||||
});
|
||||
|
||||
assert.equal(sort.canMove(items[0], -1), false);
|
||||
assert.equal(sort.canMove(items[0], 1), true);
|
||||
assert.equal(sort.canMove(items[2], 1), false);
|
||||
assert.equal(await sort.moveItem(items[1], -1), true);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.id),
|
||||
["b", "a", "c"]
|
||||
);
|
||||
assert.deepEqual(observed, [["b", "a", "c"]]);
|
||||
assert.equal(await sort.moveItem(items[0], -1), false);
|
||||
assert.equal(observed.length, 1);
|
||||
});
|
||||
|
||||
void test("accessible reorder ignores duplicate actions while persistence is pending", async () => {
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const sort = createSort(items, () => gate);
|
||||
|
||||
const firstMove = sort.moveItem(items[0], 1);
|
||||
await Promise.resolve();
|
||||
assert.equal(sort.canMove(items[2], -1), false);
|
||||
assert.equal(await sort.moveItem(items[2], -1), false);
|
||||
|
||||
release();
|
||||
assert.equal(await firstMove, true);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.id),
|
||||
["b", "a", "c"]
|
||||
);
|
||||
});
|
||||
|
||||
void test("accessible reorder restores its local order when persistence rejects", async () => {
|
||||
const items = [{ id: "a" }, { id: "b" }];
|
||||
const sort = createSort(items, () => Promise.reject(new Error("save failed")));
|
||||
|
||||
await assert.rejects(sort.moveItem(items[0], 1), /save failed/u);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.id),
|
||||
["a", "b"]
|
||||
);
|
||||
assert.equal(sort.canMove(items[0], 1), true);
|
||||
});
|
||||
|
|
@ -7,7 +7,8 @@ void test("runtime startup is layout-ready, ordered, and generation guarded", ()
|
|||
const apiManager = readFileSync("src/wallpaper-apis/core/api-manager.ts", "utf8");
|
||||
|
||||
assert.match(plugin, /onLayoutReady\(\(\) =>/u);
|
||||
assert.match(plugin, /await apiManager\.createApi\(apiConfig, false\)/u);
|
||||
assert.match(plugin, /await this\.createWallpaperApi\(apiConfig, false\)/u);
|
||||
assert.match(plugin, /await apiManager\.createApi\(runtimeConfig, activate\)/u);
|
||||
assert.match(plugin, /await apiManager\.activateConfiguredApis\(\)/u);
|
||||
assert.match(plugin, /generation !== this\.startGeneration/u);
|
||||
assert.match(plugin, /void apiManager\.suspendAllApis\(\)/u);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ void test("release CI installs the lockfile and runs the shared gate first", ()
|
|||
|
||||
void test("version authorities and mobile compatibility stay aligned", () => {
|
||||
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
version: string;
|
||||
};
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8")) as {
|
||||
|
|
@ -31,7 +32,8 @@ void test("version authorities and mobile compatibility stay aligned", () => {
|
|||
assert.equal(packageJson.version, manifest.version);
|
||||
assert.match(versionSource, new RegExp(`"${manifest.version}"`, "u"));
|
||||
assert.equal(manifest.isDesktopOnly, false);
|
||||
assert.equal(manifest.minAppVersion, "1.7.2");
|
||||
assert.equal(manifest.minAppVersion, "1.11.4");
|
||||
assert.equal(packageJson.devDependencies?.obsidian, "^1.13.1");
|
||||
assert.match(readFileSync(".gitignore", "utf8"), /^main\.js$/mu);
|
||||
});
|
||||
|
||||
|
|
@ -49,7 +51,8 @@ void test("both READMEs disclose network, credential-storage, and telemetry boun
|
|||
for (const path of ["README.md", "README.zh.md"]) {
|
||||
const readme = readFileSync(path, "utf8");
|
||||
assert.match(readme, /data\.json/u);
|
||||
assert.match(readme, /Obsidian 1\.7\.2/u);
|
||||
assert.match(readme, /Obsidian 1\.11\.4/u);
|
||||
assert.match(readme, /SecretStorage/u);
|
||||
assert.match(readme, /telemetry|遥测/u);
|
||||
assert.match(readme, /third party|第三方/u);
|
||||
}
|
||||
|
|
|
|||
32
tests/reorder-accessibility-contract.test.ts
Normal file
32
tests/reorder-accessibility-contract.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
void test("all sortable setting lists expose shared native reorder controls", () => {
|
||||
const dragSort = readFileSync("src/utils/drag-sort.ts", "utf8");
|
||||
const sections = [
|
||||
"src/settings/sections/bg-management.ts",
|
||||
"src/settings/sections/mode-settings.ts",
|
||||
"src/settings/sections/api-settings.ts",
|
||||
];
|
||||
|
||||
for (const path of sections) {
|
||||
assert.match(readFileSync(path, "utf8"), /enableDragForElement\([^,]+,\s*[^,]+,\s*[^)]+\)/u, path);
|
||||
}
|
||||
assert.match(dragSort, /container\.createEl\("button"/u);
|
||||
assert.match(dragSort, /"aria-label": label/u);
|
||||
assert.match(dragSort, /button\.disabled = !this\.canMove\(item, direction\)/u);
|
||||
});
|
||||
|
||||
void test("reorder controls retain focus and coarse-pointer target contracts", () => {
|
||||
const styles = readFileSync("styles.css", "utf8");
|
||||
const en = readFileSync("src/i18n/en.ts", "utf8");
|
||||
const zh = readFileSync("src/i18n/zh-cn.ts", "utf8");
|
||||
|
||||
assert.match(styles, /\.dtb-reorder-button \{\s*flex: 0 0 auto;\s*touch-action: manipulation;/u);
|
||||
assert.match(styles, /@media \(pointer: coarse\)[\s\S]*\.dtb-button-container \.dtb-reorder-button/u);
|
||||
for (const locale of [en, zh]) {
|
||||
assert.match(locale, /move_item_up:/u);
|
||||
assert.match(locale, /move_item_down:/u);
|
||||
}
|
||||
});
|
||||
31
tests/responsive-settings-contract.test.ts
Normal file
31
tests/responsive-settings-contract.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const styles = readFileSync("styles.css", "utf8");
|
||||
|
||||
void test("constrained settings override the desktop card direction", () => {
|
||||
assert.match(
|
||||
styles,
|
||||
/@media \(max-width: 1100px\) \{\s*:has\(> \.dtb-hint\) > \.setting-item,\s*\.dtb-large-button-container \.setting-item,\s*\.dtb-item,\s*\.dtb-section-container \.setting-item \{\s*flex-direction: column;\s*align-items: stretch;/u
|
||||
);
|
||||
assert.match(styles, /:has\(> \.dtb-hint\) > \.setting-item,/u);
|
||||
assert.match(styles, /\.dtb-large-button-container \.setting-item,/u);
|
||||
assert.match(styles, /\.dtb-bg-name \{\s*min-width: 0;\s*overflow-wrap: anywhere;/u);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.dtb-bg-content > \.dtb-button-container \{\s*flex: 1 0 100%;\s*justify-content: flex-end;/u
|
||||
);
|
||||
});
|
||||
|
||||
void test("narrow settings wrap headers and stack background content", () => {
|
||||
assert.match(
|
||||
styles,
|
||||
/\.dtb-section-header \{\s*flex-wrap: wrap;\s*justify-content: flex-start;\s*gap: 8px;/u
|
||||
);
|
||||
assert.match(styles, /\.dtb-links \{\s*flex-wrap: wrap;\s*width: 100%;\s*min-width: 0;/u);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.dtb-bg-content \{\s*flex-direction: column;\s*align-items: stretch;\s*width: 100%;\s*min-width: 0;/u
|
||||
);
|
||||
});
|
||||
33
tests/settings-migration-contract.test.ts
Normal file
33
tests/settings-migration-contract.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
void test("plugin migration and runtime creation cross the SecretStorage boundary", () => {
|
||||
const plugin = readFileSync("src/plugin.ts", "utf8");
|
||||
|
||||
assert.match(plugin, /this\.app\.secretStorage/u);
|
||||
assert.match(plugin, /migrateSettingsCredentials/u);
|
||||
assert.match(plugin, /hydrateWallpaperApiConfig/u);
|
||||
assert.match(plugin, /assertSettingsCredentialsAreReferences/u);
|
||||
assert.match(plugin, /createWallpaperApi/u);
|
||||
});
|
||||
|
||||
void test("settings use declarative definitions with an imperative compatibility fallback", () => {
|
||||
const settingsTab = readFileSync("src/settings/settings-tab.ts", "utf8");
|
||||
const settingsView = readFileSync("src/settings/settings-view.ts", "utf8");
|
||||
|
||||
assert.match(settingsTab, /getSettingDefinitions\(\)/u);
|
||||
assert.match(settingsTab, /type:\s*"page"/u);
|
||||
assert.match(settingsTab, /display\(\): void/u);
|
||||
assert.match(settingsTab, /activateSurface\(declarative: boolean\)/u);
|
||||
assert.match(settingsTab, /this\.plugin\.settingTabs\.set\(this\.componentId, this\)/u);
|
||||
assert.match(settingsView, /displayImperativeSettings/u);
|
||||
});
|
||||
|
||||
void test("credential controls store SecretStorage references instead of plaintext values", () => {
|
||||
const modal = readFileSync("src/modals/wallpaper-api-modal.ts", "utf8");
|
||||
|
||||
assert.match(modal, /SecretComponent/u);
|
||||
assert.match(modal, /secretRefs/u);
|
||||
assert.doesNotMatch(modal, /\(input as HTMLInputElement\)\.type = "password"/u);
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import type { DTBSettings } from "../src/types";
|
|||
|
||||
function settingsFixture(): DTBSettings {
|
||||
return {
|
||||
credentialStorageVersion: 1,
|
||||
enabled: true,
|
||||
statusBarEnabled: true,
|
||||
blurDepth: 0,
|
||||
|
|
|
|||
Loading…
Reference in a new issue