mirror of
https://github.com/sean2077/obsidian-dynamic-theme-background.git
synced 2026-07-22 06:44:57 +00:00
Compare commits
18 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81c7c8ad9 | ||
|
|
f76dde62d8 | ||
|
|
b74a6610b3 | ||
|
|
6eb2fc31f8 | ||
|
|
038053f036 | ||
|
|
b48ac2d153 | ||
|
|
6d265ba20b | ||
|
|
aa15c26d47 | ||
|
|
494124e906 | ||
|
|
2bce517849 | ||
|
|
08bcdc92ee | ||
|
|
b7857f20d7 | ||
|
|
bdbe762390 | ||
|
|
57ca0e101e | ||
|
|
affeda7894 | ||
|
|
f2b5ddd610 | ||
|
|
52d5120803 | ||
|
|
fc7d780cba |
64 changed files with 2299 additions and 427 deletions
79
.github/workflows/release.yml
vendored
79
.github/workflows/release.yml
vendored
|
|
@ -2,26 +2,30 @@ name: Release
|
|||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
tags:
|
||||
- "[0-9]*.[0-9]*.[0-9]*"
|
||||
|
||||
permissions:
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
@ -29,17 +33,60 @@ jobs:
|
|||
- name: Install locked dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify tag is published from master
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin master
|
||||
test "$(git cat-file -t "$GITHUB_REF_NAME")" = "tag"
|
||||
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
|
||||
test "$(git rev-parse HEAD)" = "$tag_commit"
|
||||
git merge-base --is-ancestor "$tag_commit" origin/master
|
||||
|
||||
- name: Verify release contract
|
||||
run: npm run release:verify -- "$GITHUB_REF_NAME"
|
||||
|
||||
- name: Run required checks
|
||||
run: npm run check
|
||||
|
||||
- name: Semantic Release
|
||||
uses: cycjimmy/semantic-release-action@v4
|
||||
id: semantic
|
||||
- name: Attest release assets
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
extra_plugins: |
|
||||
@semantic-release/changelog
|
||||
@semantic-release/git
|
||||
@semantic-release/exec
|
||||
conventional-changelog-conventionalcommits
|
||||
subject-path: |
|
||||
main.js
|
||||
styles.css
|
||||
|
||||
- name: Extract release notes
|
||||
run: npm run release:notes -- "$GITHUB_REF_NAME" "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
- name: Publish GitHub release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
notes="$RUNNER_TEMP/release-notes.md"
|
||||
prerelease=false
|
||||
[[ "$RELEASE_TAG" == *-* ]] && prerelease=true
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "$RELEASE_TAG" \
|
||||
--notes-file "$notes" \
|
||||
--prerelease="$prerelease"
|
||||
else
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--verify-tag \
|
||||
--target "$(git rev-parse HEAD)" \
|
||||
--title "$RELEASE_TAG" \
|
||||
--notes-file "$notes" \
|
||||
--prerelease="$prerelease" \
|
||||
--draft
|
||||
fi
|
||||
|
||||
gh release upload "$RELEASE_TAG" main.js manifest.json styles.css --clobber
|
||||
gh release edit "$RELEASE_TAG" --draft=false
|
||||
test "$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft)" = "false"
|
||||
test "$(gh release view "$RELEASE_TAG" --json assets --jq '.assets | map(.name) | sort | join(",")')" = \
|
||||
"main.js,manifest.json,styles.css"
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
branches:
|
||||
- main
|
||||
- master
|
||||
tagFormat: ${version}
|
||||
preset: conventionalcommits
|
||||
plugins:
|
||||
- "@semantic-release/commit-analyzer"
|
||||
- - "@semantic-release/release-notes-generator"
|
||||
- presetConfig:
|
||||
types:
|
||||
- type: "fix"
|
||||
section: "Fixes"
|
||||
- type: "feat"
|
||||
section: "Features"
|
||||
- type: "perf"
|
||||
section: "Performance Improvements"
|
||||
- - "@semantic-release/changelog"
|
||||
- changelogFile: CHANGELOG.md
|
||||
- - "@semantic-release/exec"
|
||||
- prepareCmd: |
|
||||
sed -i 's/"version": "[^"]*"/"version": "${nextRelease.version}"/' manifest.json
|
||||
sed -i 's/export const VERSION = ".*"/export const VERSION = "${nextRelease.version}"/' src/version.ts
|
||||
npm version ${nextRelease.version} --no-git-tag-version
|
||||
npm run build
|
||||
- - "@semantic-release/git"
|
||||
- assets:
|
||||
- CHANGELOG.md
|
||||
- manifest.json
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- src/version.ts
|
||||
message: |-
|
||||
chore(release): ${nextRelease.version} [skip ci]
|
||||
|
||||
${nextRelease.notes}
|
||||
- - "@semantic-release/github"
|
||||
- assets:
|
||||
- main.js
|
||||
- manifest.json
|
||||
- styles.css
|
||||
|
|
@ -8,7 +8,9 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- For releases, let `npm run release:prepare -- <version>` synchronize `manifest.json`, `package.json`, `package-lock.json`, and `src/version.ts`; add non-empty `CHANGELOG.md` notes, verify, merge the release worktree, then push the matching bare SemVer tag. The tag workflow validates, attests the built runtime assets, and publishes; see the development guide.
|
||||
- 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. -->
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,3 +1,28 @@
|
|||
## [2.10.2](https://github.com/sean2077/obsidian-dynamic-theme-background/compare/2.10.1...2.10.2) (2026-07-21)
|
||||
|
||||
### Fixes
|
||||
|
||||
* limit image discovery to configured folders, remove unsafe TypeScript and CSS patterns, and attest published runtime assets ([b48ac2d](https://github.com/sean2077/obsidian-dynamic-theme-background/commit/b48ac2d1533b4cf45f94e1d52d6224f531309645))
|
||||
|
||||
## [2.10.1](https://github.com/sean2077/obsidian-dynamic-theme-background/compare/2.10.0...2.10.1) (2026-07-21)
|
||||
|
||||
### Fixes
|
||||
|
||||
* publish releases only after all required plugin files pass verification and upload successfully ([2bce517](https://github.com/sean2077/obsidian-dynamic-theme-background/commit/2bce5178494c6a76ae76cee929f3a4a61c9b77fd))
|
||||
|
||||
## [2.10.0](https://github.com/sean2077/obsidian-dynamic-theme-background/compare/2.9.2...2.10.0) (2026-07-21)
|
||||
|
||||
### Features
|
||||
|
||||
* migrate API credentials to Obsidian SecretStorage ([fc7d780](https://github.com/sean2077/obsidian-dynamic-theme-background/commit/fc7d780cba4f77d7906cca09614325800f756b43))
|
||||
|
||||
### Fixes
|
||||
|
||||
* prevent settings layout clipping ([52d5120](https://github.com/sean2077/obsidian-dynamic-theme-background/commit/52d5120803d4c39759c28b3636034d90b991606e))
|
||||
* support accessible settings reordering ([f2b5ddd](https://github.com/sean2077/obsidian-dynamic-theme-background/commit/f2b5ddd6107e6c01a4d66c436a56c2ff4d9e4ad5))
|
||||
* preserve settings integrity and compatibility ([57ca0e1](https://github.com/sean2077/obsidian-dynamic-theme-background/commit/57ca0e101eee46dbabb17a2d30753803c71c5558))
|
||||
* harden runtime safety boundaries ([bdbe762](https://github.com/sean2077/obsidian-dynamic-theme-background/commit/bdbe762390168ebf9ae7f247694e94e919e03555))
|
||||
|
||||
## [2.9.2](https://github.com/sean2077/obsidian-dynamic-theme-background/compare/2.9.1...2.9.2) (2026-07-21)
|
||||
|
||||
## [2.9.1](https://github.com/sean2077/obsidian-dynamic-theme-background/compare/2.9.0...2.9.1) (2026-07-20)
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -8,7 +8,7 @@
|
|||
**🇺🇸 English | [🇨🇳 中文版](README.zh.md)**
|
||||
|
||||
<p align="center">
|
||||
<a href="https://obsidian.md/"><img
|
||||
<a href="https://community.obsidian.md/plugins/dynamic-theme-background"><img
|
||||
src="https://img.shields.io/badge/Obsidian%20Plugin-1e1e1e?logo=obsidian&logoColor=white"
|
||||
alt="Obsidian Plugin" /></a>
|
||||
<a href="https://github.com/sean2077/obsidian-dynamic-theme-background/releases/latest"><img
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
src="https://img.shields.io/github/stars/sean2077/obsidian-dynamic-theme-background"
|
||||
alt="GitHub Stars" /></a>
|
||||
<a href="https://github.com/sean2077/obsidian-dynamic-theme-background/actions"><img
|
||||
src="https://img.shields.io/github/actions/workflow/status/sean2077/obsidian-dynamic-theme-background/release.yml?branch=master"
|
||||
src="https://img.shields.io/github/actions/workflow/status/sean2077/obsidian-dynamic-theme-background/release.yml"
|
||||
alt="Build Status" /></a>
|
||||
<a href="https://github.com/sean2077/obsidian-dynamic-theme-background/blob/master/LICENSE"><img
|
||||
src="https://img.shields.io/github/license/sean2077/obsidian-dynamic-theme-background" alt="License" /></a>
|
||||
|
|
@ -46,11 +46,13 @@
|
|||
|
||||
## Installation
|
||||
|
||||
> Requires Obsidian 1.7.2 or later.
|
||||
> Requires Obsidian 1.11.4 or later.
|
||||
|
||||
### Marketplace (Pending)
|
||||
### Obsidian Community Plugins
|
||||
|
||||
> Currently in Obsidian review queue. Track: [obsidian-releases#7359](https://github.com/obsidianmd/obsidian-releases/pull/7359)
|
||||
1. Open **Settings → Community plugins → Browse**
|
||||
2. Search for [Dynamic Theme Background](https://community.obsidian.md/plugins/dynamic-theme-background)
|
||||
3. Select **Install**, then **Enable**
|
||||
|
||||
### BRAT
|
||||
|
||||
|
|
@ -78,7 +80,10 @@
|
|||
## 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.
|
||||
- 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.
|
||||
- Saving a remote wallpaper is an explicit action and writes only to the validated vault-relative folder selected in plugin settings.
|
||||
|
||||
|
|
|
|||
17
README.zh.md
17
README.zh.md
|
|
@ -8,7 +8,7 @@
|
|||
**[🇺🇸 English](README.md) | 🇨🇳 中文版**
|
||||
|
||||
<p align="center">
|
||||
<a href="https://obsidian.md/"><img
|
||||
<a href="https://community.obsidian.md/plugins/dynamic-theme-background"><img
|
||||
src="https://img.shields.io/badge/Obsidian%20Plugin-1e1e1e?logo=obsidian&logoColor=white"
|
||||
alt="Obsidian Plugin" /></a>
|
||||
<a href="https://github.com/sean2077/obsidian-dynamic-theme-background/releases/latest"><img
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
src="https://img.shields.io/github/stars/sean2077/obsidian-dynamic-theme-background"
|
||||
alt="GitHub Stars" /></a>
|
||||
<a href="https://github.com/sean2077/obsidian-dynamic-theme-background/actions"><img
|
||||
src="https://img.shields.io/github/actions/workflow/status/sean2077/obsidian-dynamic-theme-background/release.yml?branch=master"
|
||||
src="https://img.shields.io/github/actions/workflow/status/sean2077/obsidian-dynamic-theme-background/release.yml"
|
||||
alt="Build Status" /></a>
|
||||
<a href="https://github.com/sean2077/obsidian-dynamic-theme-background/blob/master/LICENSE"><img
|
||||
src="https://img.shields.io/github/license/sean2077/obsidian-dynamic-theme-background" alt="License" /></a>
|
||||
|
|
@ -46,11 +46,13 @@
|
|||
|
||||
## 安装
|
||||
|
||||
> 需要 Obsidian 1.7.2 或更高版本。
|
||||
> 需要 Obsidian 1.11.4 或更高版本。
|
||||
|
||||
### 社区市场(审核中)
|
||||
### Obsidian 社区插件市场
|
||||
|
||||
> 当前在官方审核队列。跟踪进度:[obsidian-releases#7359](https://github.com/obsidianmd/obsidian-releases/pull/7359)
|
||||
1. 打开**设置 → 社区插件 → 浏览**
|
||||
2. 搜索 [Dynamic Theme Background](https://community.obsidian.md/plugins/dynamic-theme-background)
|
||||
3. 点击**安装**,然后**启用**
|
||||
|
||||
### BRAT
|
||||
|
||||
|
|
@ -78,7 +80,10 @@
|
|||
## 网络与隐私
|
||||
|
||||
- 本地图片、纯色和渐变无需请求壁纸服务。启用壁纸提供商、使用自定义 JSON 接口或应用/保存远程图片时,可能访问所配置的第三方;相应服务的条款与隐私政策适用。
|
||||
- 为继续支持 Obsidian 1.7.2,提供商密钥、令牌和自定义请求头保存在 vault 配置目录下的插件 `data.json` 中。该文件未加密:请勿公开,检查 vault 同步目的地,并轮换任何可能泄露的凭据。
|
||||
- 提供商密钥、令牌和所有自定义请求头值通过 Obsidian SecretStorage 保存。插件的 `data.json` 只包含非敏感设置和 SecretStorage ID,不包含这些凭据值。
|
||||
- 自定义 API 的查询凭据必须使用**密钥查询参数**。原始**额外参数** JSON 只接受非敏感的字符串、数字和布尔值;请求时会把这些参数追加到自定义端点。
|
||||
- 升级后首次加载时,旧版明文凭据会先完整写入 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`.
|
||||
|
|
@ -18,16 +18,21 @@ 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 |
|
||||
|---|---|---|
|
||||
| Development bundle | `npm run dev` | Starts the esbuild watcher and writes generated `main.js`. |
|
||||
| Production/type check | `npm run build` | Runs TypeScript checking, then a minified production bundle. |
|
||||
| Deterministic tests | `npm test` | Bundles `tests/**/*.test.ts` with the existing esbuild dependency, then runs Node's built-in test runner. |
|
||||
| Project lint | `npm run lint` | Scans the manifest and TypeScript sources with the current Obsidian-specific and promise-safety rules. |
|
||||
| Project lint | `npm run lint` | Scans the manifest and TypeScript sources with the current Obsidian-specific, promise-safety, and unsafe-value rules. |
|
||||
| CSS lint | `npm run lint:css` | Applies the runtime selector and style rules to authored `styles.css`. |
|
||||
| Complete gate | `npm run check` | Runs build, TypeScript/Obsidian lint, runtime CSS lint, and deterministic tests. |
|
||||
| Research evaluator | `npm run evaluate` | Emits one JSON line with the frozen modernization score and hard-gate state. |
|
||||
| Prepare release | `npm run release:prepare -- <version>` | Bumps only the four version authorities; it does not edit notes, commit, tag, or push. |
|
||||
| Verify release | `npm run release:verify -- <version>` | Requires aligned version authorities and one non-empty CHANGELOG section. |
|
||||
| Extract release notes | `npm run release:notes -- <version> <output>` | Writes that exact CHANGELOG section to a new file without overwriting an existing path. |
|
||||
| TypeScript formatting | `npm run fmt` | Mutates all `src/**/*.ts`; inspect the resulting diff. |
|
||||
|
||||
Automated tests cover pure policies and structural lifecycle/release contracts. They do not emulate Obsidian. For behavior changes, reload the plugin in a disposable test vault and exercise the affected flow. Select from these checks rather than claiming broad coverage:
|
||||
|
|
@ -35,7 +40,9 @@ 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;
|
||||
- inspect both light and dark themes when CSS variables or overlays change;
|
||||
- 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, including settings widths below 1100 px and the mobile layout;
|
||||
- add a local background by navigating into a nested folder with Browse, then import a folder and confirm that only its direct image children are added;
|
||||
- exercise provider success, failure, and local-background fallback when wallpaper API behavior changes.
|
||||
|
||||
## Change maps and invariants
|
||||
|
|
@ -44,6 +51,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, 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`.
|
||||
|
||||
### 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.
|
||||
|
|
@ -52,7 +63,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
|
||||
|
||||
|
|
@ -64,4 +75,15 @@ 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. Ordinary feature and fix work must not pre-bump release files.
|
||||
|
||||
## Release workflow
|
||||
|
||||
Releases use bare SemVer tags such as `2.10.1`. The committed CHANGELOG is the release-note authority. Select the next version from the latest reachable bare SemVer tag with the `semver-release` rules: breaking changes bump major, `feat` bumps minor, and other releasable Conventional Commits bump patch; an exact owner-selected version wins when valid.
|
||||
|
||||
1. From an up-to-date, clean `master`, create a release worktree such as `bash .agents/tools/worktree.sh new release-2-10-1 --type chore --trunk master`.
|
||||
2. In that worktree, run `npm run release:prepare -- 2.10.1`. Add one newest-first `## [2.10.1]...` section to `CHANGELOG.md` with at least one user-facing note. If `minAppVersion` changed, update `versions.json` before continuing.
|
||||
3. Run `npm run release:verify -- 2.10.1` and `npm run check`, inspect and stage only the release files, then commit them as `release: 2.10.1`.
|
||||
4. Finish the worktree with `bash .agents/tools/worktree.sh done --trunk master`. This merges and pushes `master`; never push the tag first.
|
||||
5. At the resulting clean `master` tip, create an annotated tag with `git tag -a 2.10.1 -m "2.10.1"`, then push only that new tag with `git push origin 2.10.1`. Never move or recreate an existing tag.
|
||||
6. The tag-triggered Release workflow verifies that the tag commit is on `origin/master`, checks all version authorities and the non-empty CHANGELOG section, runs the full gate, creates GitHub provenance attestations for the generated `main.js` and authored `styles.css`, extracts that section, uploads `main.js`, `manifest.json`, and `styles.css` to a draft GitHub Release, and publishes it only after every upload succeeds. Verify the workflow, tag/commit identity, release state, all three assets, and both attestations before declaring completion. After downloading the release assets, use `gh attestation verify main.js --repo sean2077/obsidian-dynamic-theme-background` and the equivalent command for `styles.css`.
|
||||
|
|
|
|||
|
|
@ -117,13 +117,13 @@
|
|||
},
|
||||
"marketplace": {
|
||||
"title": "Community Marketplace",
|
||||
"status": "Pending Review",
|
||||
"status": "Available",
|
||||
"steps": [
|
||||
"Settings → Community plugins → <strong>Browse</strong>",
|
||||
"(After approval) Search <strong>Dynamic Theme Background</strong> / <strong>DTB</strong>",
|
||||
"Search <strong>Dynamic Theme Background</strong> / <strong>DTB</strong>",
|
||||
"Install & enable"
|
||||
],
|
||||
"note": "Currently in Obsidian review queue. Until approved, use BRAT or Manual. Track PR in <code class=\"repo-inline-code\">obsidian-releases</code>."
|
||||
"note": "Available from the official Obsidian Community Plugins catalog. <a href=\"https://community.obsidian.md/plugins/dynamic-theme-background\" target=\"_blank\" rel=\"noopener noreferrer\">View listing</a>."
|
||||
},
|
||||
"brat": {
|
||||
"title": "BRAT (Beta)",
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
"BRAT → Add beta plugin → Paste repo URL:<br><code>https://github.com/sean2077/obsidian-dynamic-theme-background</code>",
|
||||
"Enable plugin in settings"
|
||||
],
|
||||
"note": "Fastest way to try latest code before Marketplace approval."
|
||||
"note": "Use BRAT to test beta or prerelease builds before they reach the Marketplace."
|
||||
},
|
||||
"manual": {
|
||||
"title": "Manual Install",
|
||||
|
|
|
|||
|
|
@ -117,13 +117,13 @@
|
|||
},
|
||||
"marketplace": {
|
||||
"title": "社区市场",
|
||||
"status": "审核中",
|
||||
"status": "已上架",
|
||||
"steps": [
|
||||
"设置 → 社区插件 → <strong>浏览</strong>",
|
||||
"(通过后)搜索 <strong>Dynamic Theme Background</strong> / <strong>DTB</strong>",
|
||||
"搜索 <strong>Dynamic Theme Background</strong> / <strong>DTB</strong>",
|
||||
"安装并启用"
|
||||
],
|
||||
"note": "当前在官方审核队列。待通过前可使用 BRAT 或 手动 安装。跟踪 PR: <code class=\"repo-inline-code\">obsidian-releases</code>"
|
||||
"note": "已在 Obsidian 官方社区插件市场上架。<a href=\"https://community.obsidian.md/plugins/dynamic-theme-background\" target=\"_blank\" rel=\"noopener noreferrer\">查看插件页面</a>。"
|
||||
},
|
||||
"brat": {
|
||||
"title": "BRAT (Beta)",
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
"BRAT → Add beta plugin → 粘贴仓库地址:<br><code>https://github.com/sean2077/obsidian-dynamic-theme-background</code>",
|
||||
"在设置中启用插件"
|
||||
],
|
||||
"note": "在 Marketplace 上架前最快体验最新功能的方式。"
|
||||
"note": "使用 BRAT 测试尚未进入官方市场的 Beta 或预发布版本。"
|
||||
},
|
||||
"manual": {
|
||||
"title": "手动安装",
|
||||
|
|
|
|||
|
|
@ -289,13 +289,14 @@
|
|||
<h3>
|
||||
<span>🛒</span>
|
||||
<span data-i18n="installation.marketplace.title">Community Marketplace</span>
|
||||
<span class="install-status status-pending"
|
||||
data-i18n="installation.marketplace.status">Pending Review</span>
|
||||
<span class="install-status status-available"
|
||||
data-i18n="installation.marketplace.status">Available</span>
|
||||
</h3>
|
||||
<ul class="install-steps" data-install-method="marketplace"></ul>
|
||||
<div class="install-note-small" data-i18n="installation.marketplace.note">Currently in
|
||||
Obsidian review queue. Until approved, use BRAT or Manual. Track PR in <code
|
||||
class="repo-inline-code">obsidian-releases</code>.</div>
|
||||
<div class="install-note-small" data-i18n="installation.marketplace.note">Available
|
||||
from the official Obsidian Community Plugins catalog. <a
|
||||
href="https://community.obsidian.md/plugins/dynamic-theme-background"
|
||||
target="_blank" rel="noopener noreferrer">View listing</a>.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel" role="tabpanel" aria-labelledby="tab-brat" id="panel-brat" hidden>
|
||||
|
|
@ -307,8 +308,8 @@
|
|||
data-i18n="installation.brat.status">Beta</span>
|
||||
</h3>
|
||||
<ul class="install-steps" data-install-method="brat"></ul>
|
||||
<div class="install-note-small" data-i18n="installation.brat.note">Fastest way to try
|
||||
latest code before Marketplace approval.</div>
|
||||
<div class="install-note-small" data-i18n="installation.brat.note">Use BRAT to test
|
||||
beta or prerelease builds before they reach the Marketplace.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel" role="tabpanel" aria-labelledby="tab-manual" id="panel-manual" hidden>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ export default defineConfig(
|
|||
"manifest.json",
|
||||
"tools/quality/evaluate.mjs",
|
||||
"tools/quality/test.mjs",
|
||||
"tools/release/notes.mjs",
|
||||
"tools/release/prepare.mjs",
|
||||
"tools/release/verify.mjs",
|
||||
],
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
|
|
@ -49,6 +52,11 @@ export default defineConfig(
|
|||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/require-await": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error",
|
||||
"@typescript-eslint/no-unsafe-argument": "error",
|
||||
"@typescript-eslint/no-unsafe-assignment": "error",
|
||||
"@typescript-eslint/no-unsafe-call": "error",
|
||||
"@typescript-eslint/no-unsafe-member-access": "error",
|
||||
"@typescript-eslint/no-unsafe-return": "error",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
"obsidianmd/settings-tab/prefer-setting-definitions": "off",
|
||||
"obsidianmd/ui/sentence-case": ["error", sentenceCaseOptions],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"id": "dynamic-theme-background",
|
||||
"name": "Dynamic Theme Background",
|
||||
"version": "2.9.2",
|
||||
"minAppVersion": "1.7.2",
|
||||
"version": "2.10.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",
|
||||
|
|
|
|||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-dynamic-theme-background-plugin",
|
||||
"version": "2.9.2",
|
||||
"version": "2.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-dynamic-theme-background-plugin",
|
||||
"version": "2.9.2",
|
||||
"version": "2.10.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-dynamic-theme-background-plugin",
|
||||
"version": "2.9.2",
|
||||
"version": "2.10.2",
|
||||
"description": "A plugin for Obsidian that dynamically changes the background based on time of day and user settings.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
|
|
@ -14,6 +14,9 @@
|
|||
"lint:fix": "eslint . --fix",
|
||||
"lint:css": "stylelint \"styles.css\"",
|
||||
"fix:css": "stylelint \"styles.css\" --fix",
|
||||
"release:notes": "node tools/release/notes.mjs",
|
||||
"release:prepare": "node tools/release/prepare.mjs",
|
||||
"release:verify": "node tools/release/verify.mjs",
|
||||
"test": "node tools/quality/test.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
|
|
@ -27,7 +30,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",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
// 定时器间隔
|
||||
export const MIN_DELAY_MS = 1000;
|
||||
export const MS_PER_MINUTE = 60_000;
|
||||
export const MIN_INTERVAL_MINUTES = 1;
|
||||
// Browser timers clamp through a signed 32-bit delay. Larger values can wrap
|
||||
// into a near-zero interval and accidentally create a tight request loop.
|
||||
export const MAX_INTERVAL_MINUTES = Math.floor(2_147_483_647 / MS_PER_MINUTE);
|
||||
export const FALLBACK_CHECK_MS = 24 * 60 * 60 * 1000; // 24 小时
|
||||
|
||||
// 图片比例分析阈值
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@
|
|||
|
||||
import { Notice, type Plugin } from "obsidian";
|
||||
|
||||
import { FALLBACK_CHECK_MS, MIN_DELAY_MS, MS_PER_MINUTE } from "../constants";
|
||||
import {
|
||||
FALLBACK_CHECK_MS,
|
||||
MAX_INTERVAL_MINUTES,
|
||||
MIN_DELAY_MS,
|
||||
MIN_INTERVAL_MINUTES,
|
||||
MS_PER_MINUTE,
|
||||
} from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import type { BackgroundItem, DTBSettings } from "../types";
|
||||
import { apiManager } from "../wallpaper-apis";
|
||||
|
|
@ -34,6 +40,7 @@ export class BackgroundManager {
|
|||
private onSettingsMutated: (() => void) | null;
|
||||
private plugin: Plugin;
|
||||
private running = false;
|
||||
private scheduleGeneration = 0;
|
||||
private scheduler: TimeRuleScheduler;
|
||||
private styleManager: StyleManager;
|
||||
private timeoutId: number | null = null;
|
||||
|
|
@ -55,6 +62,7 @@ export class BackgroundManager {
|
|||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
this.scheduleGeneration += 1;
|
||||
this.updates.invalidate();
|
||||
if (this.intervalId !== null) {
|
||||
window.clearInterval(this.intervalId);
|
||||
|
|
@ -71,17 +79,22 @@ export class BackgroundManager {
|
|||
|
||||
start(settings: DTBSettings): void {
|
||||
this.stop();
|
||||
const scheduleGeneration = this.scheduleGeneration;
|
||||
this.running = true;
|
||||
activeDocument.body.classList.add("dtb-enabled");
|
||||
|
||||
void this.update(settings, true);
|
||||
|
||||
if (settings.mode === "time-based") {
|
||||
this.startTimeBasedMode(settings);
|
||||
this.startTimeBasedMode(settings, scheduleGeneration);
|
||||
} else if (settings.mode === "interval") {
|
||||
const intervalMs = settings.intervalMinutes * MS_PER_MINUTE;
|
||||
const intervalMinutes =
|
||||
Math.min(MAX_INTERVAL_MINUTES, Math.max(MIN_INTERVAL_MINUTES, settings.intervalMinutes)) ||
|
||||
MIN_INTERVAL_MINUTES;
|
||||
const intervalMs = intervalMinutes * MS_PER_MINUTE;
|
||||
this.intervalId = this.plugin.registerInterval(
|
||||
window.setInterval(() => {
|
||||
if (!this.isCurrentSchedule(scheduleGeneration)) return;
|
||||
void this.update(settings, false);
|
||||
}, intervalMs)
|
||||
);
|
||||
|
|
@ -96,14 +109,14 @@ export class BackgroundManager {
|
|||
}
|
||||
}
|
||||
|
||||
private startTimeBasedMode(settings: DTBSettings): void {
|
||||
private startTimeBasedMode(settings: DTBSettings, generation: number): void {
|
||||
const scheduleNext = () => {
|
||||
if (!this.running) return;
|
||||
if (!this.isCurrentSchedule(generation)) return;
|
||||
const nextRuleChange = this.scheduler.getNextChangeTime();
|
||||
if (nextRuleChange !== null) {
|
||||
const delay = Math.max(nextRuleChange - Date.now(), MIN_DELAY_MS);
|
||||
this.timeoutId = window.setTimeout(() => {
|
||||
if (!this.running) return;
|
||||
if (!this.isCurrentSchedule(generation)) return;
|
||||
void this.update(settings, false);
|
||||
const currentRuleId = this.scheduler.getCurrentRule()?.id ?? null;
|
||||
if (currentRuleId !== this.lastActiveRuleId) {
|
||||
|
|
@ -119,7 +132,7 @@ export class BackgroundManager {
|
|||
});
|
||||
} else {
|
||||
this.timeoutId = window.setTimeout(() => {
|
||||
if (!this.running) return;
|
||||
if (!this.isCurrentSchedule(generation)) return;
|
||||
void this.update(settings, false);
|
||||
scheduleNext();
|
||||
}, FALLBACK_CHECK_MS);
|
||||
|
|
@ -130,6 +143,10 @@ export class BackgroundManager {
|
|||
scheduleNext();
|
||||
}
|
||||
|
||||
private isCurrentSchedule(generation: number): boolean {
|
||||
return generation === this.scheduleGeneration;
|
||||
}
|
||||
|
||||
async update(settings: DTBSettings, forceUpdate = true): Promise<void> {
|
||||
if (!settings.enabled || !this.running) return;
|
||||
if (!forceUpdate && this.updates.isRunning) return;
|
||||
|
|
|
|||
256
src/core/credential-storage.ts
Normal file
256
src/core/credential-storage.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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;
|
||||
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;
|
||||
setSecret(id: string, secret: string): void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneWallpaperApiConfig(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(cloneWallpaperApiConfig),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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 resolveCredentialFields(config, resolveSensitiveParams)) {
|
||||
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 = cloneWallpaperApiConfig(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 = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
export { BackgroundManager } from "./background-manager";
|
||||
export {
|
||||
MissingSecretReferenceError,
|
||||
assertSettingsCredentialsAreReferences,
|
||||
cloneWallpaperApiConfig,
|
||||
hydrateWallpaperApiConfig,
|
||||
migrateSettingsCredentials,
|
||||
} from "./credential-storage";
|
||||
export { selectBackgroundPlan } from "./background-selection";
|
||||
export type { BackgroundSelectionPlan } from "./background-selection";
|
||||
export { BackgroundPersistence } from "./background-persistence";
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ export const MAX_REMOTE_URL_LENGTH = 2_048;
|
|||
export const MAX_RESPONSE_BYTES = 2 * 1_024 * 1_024;
|
||||
export const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
const SECRET_KEY = /(?:api[-_]?key|authorization|client[-_]?id|secret|token)/iu;
|
||||
const SECRET_QUERY = /([?&](?:api[-_]?key|client[-_]?id|key|token)=)[^&#\s]*/giu;
|
||||
const AUTH_VALUE = /\b(?:Bearer|Client-ID)\s+[^\s,;]+/giu;
|
||||
const SECRET_NAME =
|
||||
/(?:api[-_]?key|access[-_]?key|auth|client[-_.]?id|cookie|pass(?:word|phrase)|secret|token|(?:^|[-_.])key(?:$|[-_.]))/iu;
|
||||
const SECRET_QUERY =
|
||||
/([?&#][^=&#\s]*(?:auth|client[-_.]?id|cookie|key|pass(?:word|phrase)|secret|token)[^=&#\s]*=)[^&#\s]*/giu;
|
||||
const AUTH_VALUE = /\b(?:Basic|Bearer|Client-ID|Token)\s+[^\s,;]+/giu;
|
||||
|
||||
export interface RemoteUrlOptions {
|
||||
allowInsecureHttp?: boolean;
|
||||
|
|
@ -85,7 +87,7 @@ export function sanitizeForLog(value: unknown, depth = 0): unknown {
|
|||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
sanitized[key] = SECRET_KEY.test(key) ? "[REDACTED]" : sanitizeForLog(item, depth + 1);
|
||||
sanitized[key] = SECRET_NAME.test(key) ? "[REDACTED]" : sanitizeForLog(item, depth + 1);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { BackgroundItem, DTBSettings, TimeRule } from "../types";
|
||||
import { MAX_INTERVAL_MINUTES, MIN_INTERVAL_MINUTES } from "../constants";
|
||||
import { WallpaperApiType } from "../wallpaper-apis/core/types";
|
||||
import type { ApiValueType, WallpaperApiConfig } from "../wallpaper-apis/core/types";
|
||||
import { isRecord } from "../utils/type-guards";
|
||||
|
|
@ -91,11 +92,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 +149,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),
|
||||
|
|
@ -157,7 +173,12 @@ export function normalizeSettings(loaded: unknown, defaults: DTBSettings): DTBSe
|
|||
? (loaded.mode as DTBSettings["mode"])
|
||||
: fallback.mode,
|
||||
timeRules: arrayOrDefault(loaded.timeRules, fallback.timeRules, isTimeRule),
|
||||
intervalMinutes: numberOrDefault(loaded.intervalMinutes, fallback.intervalMinutes, 1),
|
||||
intervalMinutes: numberOrDefault(
|
||||
loaded.intervalMinutes,
|
||||
fallback.intervalMinutes,
|
||||
MIN_INTERVAL_MINUTES,
|
||||
MAX_INTERVAL_MINUTES
|
||||
),
|
||||
localBackgroundFolder: stringOrDefault(loaded.localBackgroundFolder, fallback.localBackgroundFolder),
|
||||
backgrounds,
|
||||
currentIndex,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
*/
|
||||
|
||||
import { t } from "./i18n";
|
||||
import { CURRENT_CREDENTIAL_STORAGE_VERSION } from "./core/credential-storage";
|
||||
import type { DTBSettings } from "./types";
|
||||
import { apiRegistry, WallpaperApiType } from "./wallpaper-apis";
|
||||
|
||||
let DEFAULT_SETTINGS: DTBSettings;
|
||||
|
||||
function genDefaultSettings(): DTBSettings {
|
||||
DEFAULT_SETTINGS = {
|
||||
return {
|
||||
credentialStorageVersion: CURRENT_CREDENTIAL_STORAGE_VERSION,
|
||||
enabled: true,
|
||||
statusBarEnabled: true, // 是否激活状态栏
|
||||
|
||||
|
|
@ -168,9 +168,8 @@ function genDefaultSettings(): DTBSettings {
|
|||
},
|
||||
],
|
||||
};
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
export function getDefaultSettings(): DTBSettings {
|
||||
return DEFAULT_SETTINGS || genDefaultSettings();
|
||||
return genDefaultSettings();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -209,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...",
|
||||
|
|
@ -218,16 +224,22 @@ 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, 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",
|
||||
|
|
@ -242,6 +254,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)",
|
||||
|
|
@ -192,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 配置...",
|
||||
|
|
@ -201,16 +206,21 @@ 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 获取地址",
|
||||
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",
|
||||
|
|
@ -225,6 +235,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} 张壁纸。",
|
||||
|
||||
// ===== 状态文本 =====
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export class BackgroundModal extends Modal {
|
|||
// 背景单独的模糊度、亮度、饱和度、遮罩颜色和透明度、填充方式设置
|
||||
contentEl.createEl("h4", { text: t("appearance_settings_title") });
|
||||
// 提示:重置仅清空当前背景的覆盖值,继承全局设置
|
||||
contentEl.addClass("dtb-hint-section");
|
||||
const hint = contentEl.createDiv({ cls: "dtb-hint" });
|
||||
hint.setText(t("per_bg_reset_hint"));
|
||||
const appearanceContainer = contentEl.createDiv();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { App, SuggestModal } from "obsidian";
|
||||
import { type App, SuggestModal, TFolder } from "obsidian";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export class ImageFolderSuggestModal extends SuggestModal<string> {
|
||||
onSubmit: (folderPath: string) => void;
|
||||
export class ImageFolderSuggestModal extends SuggestModal<TFolder> {
|
||||
private readonly onSubmit: (folderPath: string) => void;
|
||||
|
||||
constructor(app: App, onSubmit: (folderPath: string) => void) {
|
||||
super(app);
|
||||
|
|
@ -10,23 +10,28 @@ export class ImageFolderSuggestModal extends SuggestModal<string> {
|
|||
this.setPlaceholder(t("folder_path_placeholder"));
|
||||
}
|
||||
|
||||
getSuggestions(query: string): string[] {
|
||||
if (query.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
const folders = this.app.vault.getAllFolders();
|
||||
return folders
|
||||
.map((folder) => folder.path)
|
||||
.filter((path) => path.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort()
|
||||
getSuggestions(query: string): TFolder[] {
|
||||
const normalizedQuery = query.trim().replace(/^\/+/, "");
|
||||
if (normalizedQuery === "") return [];
|
||||
|
||||
const separatorIndex = normalizedQuery.lastIndexOf("/");
|
||||
const parentPath = separatorIndex >= 0 ? normalizedQuery.slice(0, separatorIndex) : "";
|
||||
const nameQuery = normalizedQuery.slice(separatorIndex + 1).toLowerCase();
|
||||
const parent = parentPath === "" ? this.app.vault.getRoot() : this.app.vault.getFolderByPath(parentPath);
|
||||
if (!parent) return [];
|
||||
|
||||
return parent.children
|
||||
.filter((entry): entry is TFolder => entry instanceof TFolder)
|
||||
.filter((folder) => folder.name.toLowerCase().includes(nameQuery))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
renderSuggestion(folderPath: string, el: HTMLElement) {
|
||||
el.createDiv({ text: folderPath });
|
||||
renderSuggestion(folder: TFolder, el: HTMLElement): void {
|
||||
el.createDiv({ text: folder.path });
|
||||
}
|
||||
|
||||
onChooseSuggestion(folderPath: string, _evt: MouseEvent | KeyboardEvent) {
|
||||
this.onSubmit(folderPath);
|
||||
onChooseSuggestion(folder: TFolder, _evt: MouseEvent | KeyboardEvent): void {
|
||||
this.onSubmit(folder.path);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { App, SuggestModal } from "obsidian";
|
||||
import { type App, SuggestModal, type TAbstractFile, TFile, TFolder } from "obsidian";
|
||||
|
||||
export class ImagePathSuggestModal extends SuggestModal<string> {
|
||||
onSubmit: (imagePath: string) => void;
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"]);
|
||||
|
||||
function isImageFile(file: TFile): boolean {
|
||||
return IMAGE_EXTENSIONS.has(file.extension.toLowerCase());
|
||||
}
|
||||
|
||||
export class ImagePathSuggestModal extends SuggestModal<TAbstractFile> {
|
||||
private readonly onSubmit: (imagePath: string) => void;
|
||||
|
||||
constructor(app: App, onSubmit: (imagePath: string) => void) {
|
||||
super(app);
|
||||
|
|
@ -9,32 +15,56 @@ export class ImagePathSuggestModal extends SuggestModal<string> {
|
|||
this.setPlaceholder("https://example.com/image.jpg OR path/to/image.jpg");
|
||||
}
|
||||
|
||||
getSuggestions(query: string): string[] {
|
||||
if (query.trim() === "") {
|
||||
getSuggestions(query: string): TAbstractFile[] {
|
||||
const normalizedQuery = query.trim().replace(/^\/+/, "");
|
||||
if (normalizedQuery === "") {
|
||||
return [];
|
||||
}
|
||||
if (query.startsWith("http://") || query.startsWith("https://") || query.startsWith("www.")) {
|
||||
if (
|
||||
normalizedQuery.startsWith("http://") ||
|
||||
normalizedQuery.startsWith("https://") ||
|
||||
normalizedQuery.startsWith("www.")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const files = this.app.vault.getFiles();
|
||||
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"];
|
||||
return files
|
||||
.filter((file) => imageExtensions.some((ext) => file.path.toLowerCase().endsWith(ext)))
|
||||
.map((file) => file.path)
|
||||
.filter((path) => path.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort()
|
||||
|
||||
const separatorIndex = normalizedQuery.lastIndexOf("/");
|
||||
const folderPath = separatorIndex >= 0 ? normalizedQuery.slice(0, separatorIndex) : "";
|
||||
const nameQuery = normalizedQuery.slice(separatorIndex + 1).toLowerCase();
|
||||
const folder = folderPath === "" ? this.app.vault.getRoot() : this.app.vault.getFolderByPath(folderPath);
|
||||
if (!folder) return [];
|
||||
|
||||
return folder.children
|
||||
.filter((entry) => entry instanceof TFolder || (entry instanceof TFile && isImageFile(entry)))
|
||||
.filter((entry) => entry.name.toLowerCase().includes(nameQuery))
|
||||
.sort((left, right) => {
|
||||
const folderOrder = Number(right instanceof TFolder) - Number(left instanceof TFolder);
|
||||
return folderOrder || left.path.localeCompare(right.path);
|
||||
})
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
renderSuggestion(imagePath: string, el: HTMLElement) {
|
||||
renderSuggestion(entry: TAbstractFile, el: HTMLElement): void {
|
||||
const container = el.createDiv({ cls: "dtb-suggestion" });
|
||||
const icon = container.createSpan();
|
||||
icon.textContent = "🖼️";
|
||||
icon.textContent = entry instanceof TFolder ? "📁" : "🖼️";
|
||||
const text = container.createSpan();
|
||||
text.textContent = imagePath;
|
||||
text.textContent = entry.path;
|
||||
}
|
||||
|
||||
onChooseSuggestion(imagePath: string, _evt: MouseEvent | KeyboardEvent) {
|
||||
this.onSubmit(imagePath);
|
||||
selectSuggestion(entry: TAbstractFile, evt: MouseEvent | KeyboardEvent): void {
|
||||
if (entry instanceof TFolder) {
|
||||
this.inputEl.value = `${entry.path}/`;
|
||||
this.inputEl.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
this.inputEl.focus();
|
||||
return;
|
||||
}
|
||||
super.selectSuggestion(entry, evt);
|
||||
}
|
||||
|
||||
onChooseSuggestion(entry: TAbstractFile, _evt: MouseEvent | KeyboardEvent): void {
|
||||
if (entry instanceof TFile && isImageFile(entry)) {
|
||||
this.onSubmit(entry.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { App, Modal, Notice } from "obsidian";
|
||||
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";
|
||||
import { isRecord } from "../utils/type-guards";
|
||||
import { t } from "../i18n";
|
||||
|
|
@ -14,12 +16,19 @@ 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 配置。
|
||||
*/
|
||||
export class WallpaperApiEditorModal extends Modal {
|
||||
plugin: DynamicThemeBackgroundPlugin;
|
||||
apiConfig: WallpaperApiConfig;
|
||||
onSubmit: (apiConfig: WallpaperApiConfig) => void;
|
||||
onSubmit: (apiConfig: WallpaperApiConfig) => void | Promise<void>;
|
||||
|
||||
// 基础配置输入元素
|
||||
nameInput!: HTMLInputElement;
|
||||
|
|
@ -28,12 +37,15 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
urlInput!: HTMLInputElement;
|
||||
// Headers配置
|
||||
headersContainer!: HTMLDivElement;
|
||||
headerInputs: Array<{ key: HTMLInputElement; value: HTMLInputElement }> = [];
|
||||
headerInputs: SecretInput[] = [];
|
||||
|
||||
// 参数配置容器的引用
|
||||
paramsSectionContainer!: HTMLElement;
|
||||
// 动态参数输入元素映射
|
||||
paramInputs: Map<string, HTMLElement> = new Map();
|
||||
secretParamRefs: Map<string, string> = new Map();
|
||||
secretParamsContainer!: HTMLDivElement;
|
||||
extraSecretParamInputs: SecretInput[] = [];
|
||||
// 额外参数输入
|
||||
extraParamsTextarea!: HTMLTextAreaElement;
|
||||
|
||||
|
|
@ -42,8 +54,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 +155,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.addSecretInput("header", key, secretId);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -151,41 +168,7 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
type: "button",
|
||||
cls: "dtb-button",
|
||||
});
|
||||
addHeaderBtn.onclick = () => this.addHeaderInput();
|
||||
}
|
||||
|
||||
// 添加header输入行
|
||||
private addHeaderInput(key = "", value = "") {
|
||||
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 valueInput = headerRow.createEl("input", {
|
||||
type: "text",
|
||||
value: value,
|
||||
placeholder: t("api_modal_header_value"),
|
||||
cls: "dtb-input",
|
||||
});
|
||||
|
||||
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({ key: keyInput, value: valueInput });
|
||||
addHeaderBtn.onclick = () => this.addSecretInput("header");
|
||||
}
|
||||
|
||||
// 创建参数配置部分
|
||||
|
|
@ -198,6 +181,8 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
private refreshParamsSection() {
|
||||
// 清除现有的参数输入
|
||||
this.paramInputs.clear();
|
||||
this.secretParamRefs.clear();
|
||||
this.extraSecretParamInputs = [];
|
||||
|
||||
// 使用保存的容器引用而不是DOM查询
|
||||
if (!this.paramsSectionContainer) return;
|
||||
|
|
@ -209,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) {
|
||||
|
|
@ -218,6 +203,9 @@ export class WallpaperApiEditorModal extends Modal {
|
|||
|
||||
// 额外参数JSON输入
|
||||
this.createExtraParamsInput(this.paramsSectionContainer);
|
||||
if (selectedType === WallpaperApiType.Custom) {
|
||||
this.createSecretParamsSection(this.paramsSectionContainer, paramDescriptors);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建参数部分的标题和文档链接
|
||||
|
|
@ -291,11 +279,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 +305,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]) => {
|
||||
|
|
@ -324,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");
|
||||
|
|
@ -381,7 +436,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 +453,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 +511,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,13 +641,13 @@ 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();
|
||||
};
|
||||
}
|
||||
|
||||
// 构建API配置对象
|
||||
buildApiConfig(): WallpaperApiConfig {
|
||||
buildApiConfig(): WallpaperApiConfig | null {
|
||||
const selectedType = this.typeSelect.value;
|
||||
|
||||
// 收集动态参数
|
||||
|
|
@ -607,23 +674,64 @@ 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) {
|
||||
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 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;
|
||||
|
||||
return {
|
||||
id: this.apiConfig.id || generateId("api"),
|
||||
|
|
@ -633,7 +741,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 +807,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 (!config || !this.prepareValidatedConfig(config)) {
|
||||
return;
|
||||
}
|
||||
this.onSubmit(config);
|
||||
await this.onSubmit(config);
|
||||
this.close();
|
||||
}
|
||||
|
||||
|
|
@ -719,15 +841,20 @@ 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();
|
||||
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"));
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
* 从 DTBSettingTab 中提取的独立 section 类,负责壁纸 API 的显示、添加、编辑、删除和拖拽排序
|
||||
*/
|
||||
import { Notice, Setting } from "obsidian";
|
||||
import { cloneWallpaperApiConfig } from "../../core";
|
||||
import { logger } from "../../core/logger";
|
||||
import { redactSensitiveText } from "../../core/network-policy";
|
||||
import { t } from "../../i18n";
|
||||
import { WallpaperApiEditorModal } from "../../modals";
|
||||
import type DynamicThemeBackgroundPlugin from "../../plugin";
|
||||
|
|
@ -15,6 +17,7 @@ import {
|
|||
WallpaperApiConfig,
|
||||
WallpaperApiType,
|
||||
apiManager,
|
||||
apiRegistry,
|
||||
} from "../../wallpaper-apis";
|
||||
|
||||
export class ApiSettingsSection {
|
||||
|
|
@ -70,7 +73,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;
|
||||
|
||||
|
|
@ -79,18 +82,20 @@ export class ApiSettingsSection {
|
|||
const existingApi = this.plugin.settings.wallpaperApis.find((api) => api.id === apiConfig.id);
|
||||
if (!existingApi) {
|
||||
// 如果不存在,则添加并创建 API 实例
|
||||
this.plugin.settings.wallpaperApis.push(apiConfig);
|
||||
void apiManager.createApi(apiConfig, this.plugin.settings.enabled);
|
||||
const restoredConfig = cloneWallpaperApiConfig(apiConfig);
|
||||
this.plugin.settings.wallpaperApis.push(restoredConfig);
|
||||
await this.plugin.createWallpaperApi(restoredConfig, this.plugin.settings.enabled);
|
||||
}
|
||||
}
|
||||
new Notice(t("restore_default_apis_success"));
|
||||
|
||||
void this.plugin.saveSettings();
|
||||
await this.plugin.saveSettings();
|
||||
this.display(this.container);
|
||||
});
|
||||
});
|
||||
|
||||
// 添加 API 提示
|
||||
containerEl.addClass("dtb-hint-section");
|
||||
const hint = containerEl.createDiv("dtb-hint");
|
||||
hint.textContent = t("wallpaper_api_hint");
|
||||
|
||||
|
|
@ -108,28 +113,34 @@ 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",
|
||||
onReorder: async (reorderedApis) => {
|
||||
this.plugin.settings.wallpaperApis = reorderedApis;
|
||||
reorderLabels: { up: t("move_item_up"), down: t("move_item_down") },
|
||||
setItems: (apis) => {
|
||||
this.plugin.settings.wallpaperApis = apis;
|
||||
},
|
||||
onReorder: async () => {
|
||||
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 +152,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 +168,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 +258,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 +281,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 +299,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 +313,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();
|
||||
});
|
||||
|
|
@ -359,7 +368,8 @@ export class ApiSettingsSection {
|
|||
new Notice(t("notice_api_failed_fetch", { apiName: api.getName() }));
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice(t("notice_api_error_fetch", { apiName: api.getName(), error: (error as Error).message }));
|
||||
const message = error instanceof Error ? redactSensitiveText(error.message) : "Remote request failed";
|
||||
new Notice(t("notice_api_error_fetch", { apiName: api.getName(), error: message }));
|
||||
logger.error("Error fetching wallpaper:", error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export class BasicSettingsSection {
|
|||
// 外观设置
|
||||
new Setting(containerEl).setName(t("appearance_settings_title")).setHeading();
|
||||
// 外观设置提示(全局外观优先级说明)
|
||||
containerEl.addClass("dtb-hint-section");
|
||||
const appearanceHint = containerEl.createDiv("dtb-hint");
|
||||
appearanceHint.textContent = t("appearance_settings_hint");
|
||||
// 背景模糊度设置
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export class BgManagementSection {
|
|||
);
|
||||
|
||||
// 添加拖拽提示
|
||||
containerEl.addClass("dtb-hint-section");
|
||||
const dragHint = containerEl.createDiv("dtb-hint");
|
||||
dragHint.textContent = t("background_management_hint");
|
||||
|
||||
|
|
@ -106,27 +107,31 @@ 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",
|
||||
onReorder: async (reorderedBackgrounds) => {
|
||||
reorderLabels: { up: t("move_item_up"), down: t("move_item_down") },
|
||||
setItems: (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;
|
||||
}
|
||||
},
|
||||
onReorder: async () => {
|
||||
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 +190,7 @@ export class BgManagementSection {
|
|||
this.onChanged?.();
|
||||
};
|
||||
|
||||
// 启用拖拽功能
|
||||
this.backgroundDragSort?.enableDragForElement(bgEl, bg);
|
||||
backgroundDragSort.enableDragForElement(bgEl, bg, actions);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -376,10 +380,7 @@ export class BgManagementSection {
|
|||
const folder = this.plugin.app.vault.getFolderByPath(folderPath);
|
||||
if (folder) {
|
||||
// 只获取该文件夹下的直接子文件(不递归)
|
||||
folderFiles = this.plugin.app.vault.getFiles().filter((file) => {
|
||||
const fileDir = file.path.substring(0, file.path.lastIndexOf("/"));
|
||||
return fileDir === folderPath;
|
||||
});
|
||||
folderFiles = folder.children.filter((entry): entry is TFile => entry instanceof TFile);
|
||||
} else {
|
||||
new Notice(t("folder_not_found"));
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { Notice, Setting } from "obsidian";
|
||||
import { MAX_INTERVAL_MINUTES, MIN_INTERVAL_MINUTES } from "../../constants";
|
||||
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;
|
||||
|
|
@ -86,7 +92,7 @@ export class ModeSettingsSection {
|
|||
button.setButtonText(t("reset_time_rules_button"));
|
||||
button.setTooltip(t("reset_time_rules_tooltip"));
|
||||
button.onClick(() => {
|
||||
this.plugin.settings.timeRules = this.defaultSettings.timeRules;
|
||||
this.plugin.settings.timeRules = this.defaultSettings.timeRules.map((rule) => ({ ...rule }));
|
||||
this.plugin.startBackgroundManager(); // 重新启动背景管理器以应用更改
|
||||
void this.plugin.saveSettings();
|
||||
this.display(this.container);
|
||||
|
|
@ -94,6 +100,7 @@ export class ModeSettingsSection {
|
|||
});
|
||||
|
||||
// 添加时间规则提示
|
||||
containerEl.addClass("dtb-hint-section");
|
||||
const hint = containerEl.createDiv("dtb-hint");
|
||||
hint.textContent = t("time_rule_hint");
|
||||
|
||||
|
|
@ -109,17 +116,29 @@ export class ModeSettingsSection {
|
|||
new Setting(containerEl)
|
||||
.setName(t("interval_name"))
|
||||
.setDesc(t("interval_desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.addText((text) => {
|
||||
text.inputEl.type = "number";
|
||||
text.inputEl.min = String(MIN_INTERVAL_MINUTES);
|
||||
text.inputEl.max = String(MAX_INTERVAL_MINUTES);
|
||||
text.inputEl.step = "1";
|
||||
return text
|
||||
.setPlaceholder("60")
|
||||
.setValue(this.plugin.settings.intervalMinutes.toString())
|
||||
.onChange(async (value) => {
|
||||
const minutes = parseInt(value) || 60;
|
||||
const minutes = Number(value);
|
||||
if (
|
||||
!Number.isInteger(minutes) ||
|
||||
minutes < MIN_INTERVAL_MINUTES ||
|
||||
minutes > MAX_INTERVAL_MINUTES ||
|
||||
minutes === this.plugin.settings.intervalMinutes
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.plugin.settings.intervalMinutes = minutes;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.startBackgroundManager();
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 随机壁纸设置
|
||||
new Setting(containerEl)
|
||||
|
|
@ -143,18 +162,21 @@ 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",
|
||||
onReorder: async (reorderedRules) => {
|
||||
this.plugin.settings.timeRules = reorderedRules;
|
||||
reorderLabels: { up: t("move_item_up"), down: t("move_item_down") },
|
||||
setItems: (rules) => {
|
||||
this.plugin.settings.timeRules = rules;
|
||||
},
|
||||
onReorder: async () => {
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.startBackgroundManager();
|
||||
this.displayTimeRules();
|
||||
},
|
||||
});
|
||||
this.timeRuleDragSort = timeRuleDragSort;
|
||||
|
||||
// 获取当前激活的时间规则
|
||||
const activeRule = this.plugin.getCurrentTimeRule();
|
||||
|
|
@ -217,15 +239,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,42 @@ 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;
|
||||
/** Publish replacement arrays owned outside this module; also used for rollback. */
|
||||
setItems?: (items: 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 +60,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 +73,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 +108,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 +152,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;
|
||||
|
|
@ -173,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");
|
||||
|
|
@ -231,10 +223,49 @@ 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export const VERSION = "2.9.2";
|
||||
export const VERSION = "2.10.2";
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export * from "./api-state-manager";
|
|||
export * from "./base-api";
|
||||
export * from "./provider-responses";
|
||||
export * from "./types";
|
||||
export * from "./url-params";
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
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,
|
||||
})
|
||||
|
|
|
|||
154
styles.css
154
styles.css
|
|
@ -84,20 +84,20 @@
|
|||
============================================================================= */
|
||||
|
||||
/* 工作区背景 */
|
||||
.dtb-enabled .workspace-split, /* 工作区分割容器 - 主要布局分割区域 */
|
||||
.dtb-enabled .workspace-leaf, /* 工作区叶子节点 - 单个面板容器 */
|
||||
.dtb-enabled .workspace-tab-header-container, /* 标签页头部容器 - 包含所有标签页 */
|
||||
.dtb-enabled .workspace-tab-header, /* 单个标签页头部 - 每个标签页的头部 */
|
||||
.dtb-enabled .view-header, /* 视图头部 - 文件/视图的顶部区域 */
|
||||
.dtb-enabled .view-content, /* 视图内容区 - 主要内容显示区域 */
|
||||
.dtb-enabled .markdown-source-view, /* Markdown源码视图 - 编辑模式 */
|
||||
.dtb-enabled .markdown-preview-view, /* Markdown预览视图 - 预览模式 */
|
||||
.dtb-enabled .cm-editor /* CodeMirror编辑器 - 代码编辑器组件 */ {
|
||||
body.dtb-enabled .workspace-split, /* 工作区分割容器 - 主要布局分割区域 */
|
||||
body.dtb-enabled .workspace-leaf, /* 工作区叶子节点 - 单个面板容器 */
|
||||
body.dtb-enabled .workspace-tab-header-container, /* 标签页头部容器 - 包含所有标签页 */
|
||||
body.dtb-enabled .workspace-tab-header, /* 单个标签页头部 - 每个标签页的头部 */
|
||||
body.dtb-enabled .view-header, /* 视图头部 - 文件/视图的顶部区域 */
|
||||
body.dtb-enabled .view-content, /* 视图内容区 - 主要内容显示区域 */
|
||||
body.dtb-enabled .markdown-source-view, /* Markdown源码视图 - 编辑模式 */
|
||||
body.dtb-enabled .markdown-preview-view, /* Markdown预览视图 - 预览模式 */
|
||||
body.dtb-enabled .cm-editor /* CodeMirror编辑器 - 代码编辑器组件 */ {
|
||||
/* 将所有背景相关的CSS变量覆盖为动态背景颜色 */
|
||||
--background-primary: var(--dtb-bg-color) !important; /* 主要背景色 */
|
||||
--background-primary-alt: var(--dtb-bg-color) !important; /* 主要背景色变体 */
|
||||
--background-secondary: var(--dtb-bg-color) !important; /* 次要背景色 */
|
||||
--background-secondary-alt: var(--dtb-bg-color) !important; /* 次要背景色变体 */
|
||||
--background-primary: var(--dtb-bg-color); /* 主要背景色 */
|
||||
--background-primary-alt: var(--dtb-bg-color); /* 主要背景色变体 */
|
||||
--background-secondary: var(--dtb-bg-color); /* 次要背景色 */
|
||||
--background-secondary-alt: var(--dtb-bg-color); /* 次要背景色变体 */
|
||||
}
|
||||
|
||||
/* 通过主题类切换遮罩变量:将 light/dark 两套变量映射到统一 --dtb-bg-color */
|
||||
|
|
@ -120,17 +120,17 @@
|
|||
}
|
||||
|
||||
/* 针对工作区其他部分的修改 */
|
||||
.dtb-enabled .workspace-tab-header-container,
|
||||
.dtb-enabled .workspace-tab-header-container .workspace-tab-header.tappable.is-active,
|
||||
.dtb-enabled .workspace-tab-header.tappable.is-active,
|
||||
.dtb-enabled .workspace-split.mod-vertical.mod-root,
|
||||
.dtb-enabled .workspace-split.mod-horizontal.mod-sidedock.mod-left-split,
|
||||
.dtb-enabled .workspace-split.mod-horizontal.mod-sidedock.mod-right-split,
|
||||
.dtb-enabled .titlebar-button-container.mod-right,
|
||||
.dtb-enabled .status-bar,
|
||||
.dtb-enabled .workspace-ribbon::before,
|
||||
.dtb-enabled .workspace-ribbon {
|
||||
background-color: var(--dtb-bg-color) !important;
|
||||
body.dtb-enabled .workspace-tab-header-container,
|
||||
body.dtb-enabled .workspace-tab-header-container .workspace-tab-header.tappable.is-active,
|
||||
body.dtb-enabled .workspace-tab-header.tappable.is-active,
|
||||
body.dtb-enabled .workspace-split.mod-vertical.mod-root,
|
||||
body.dtb-enabled .workspace-split.mod-horizontal.mod-sidedock.mod-left-split,
|
||||
body.dtb-enabled .workspace-split.mod-horizontal.mod-sidedock.mod-right-split,
|
||||
body.dtb-enabled .titlebar-button-container.mod-right,
|
||||
body.dtb-enabled .status-bar,
|
||||
body.dtb-enabled .workspace-ribbon::before,
|
||||
body.dtb-enabled .workspace-ribbon {
|
||||
background-color: var(--dtb-bg-color);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
|
|
@ -155,11 +155,11 @@
|
|||
}
|
||||
|
||||
/* 设置项 - 紧凑现代卡片 */
|
||||
.dtb-item,
|
||||
.dtb-section-container .setting-item {
|
||||
body .dtb-item,
|
||||
body .dtb-section-container .setting-item {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem !important;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-primary-alt);
|
||||
transition:
|
||||
background-color var(--dtb-anim-normal-curve),
|
||||
|
|
@ -168,10 +168,10 @@
|
|||
transform var(--dtb-anim-normal-curve);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
box-shadow: var(--dtb-shadow-base);
|
||||
}
|
||||
|
||||
|
|
@ -282,10 +282,10 @@
|
|||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dtb-item,
|
||||
.dtb-section-container .setting-item {
|
||||
padding: 0.6rem !important;
|
||||
gap: 0.8rem !important;
|
||||
body .dtb-item,
|
||||
body .dtb-section-container .setting-item {
|
||||
padding: 0.6rem;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
.dtb-hint-section > .setting-item,
|
||||
.dtb-large-button-container .setting-item,
|
||||
body .dtb-item,
|
||||
body .dtb-section-container .setting-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.dtb-hint-section > .setting-item .setting-item-info,
|
||||
.dtb-large-button-container .setting-item .setting-item-info,
|
||||
body .dtb-section-container .setting-item .setting-item-info {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.dtb-hint-section > .setting-item .setting-item-control,
|
||||
.dtb-large-button-container .setting-item .setting-item-control,
|
||||
body .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;
|
||||
}
|
||||
|
|
|
|||
267
tests/credential-storage.test.ts
Normal file
267
tests/credential-storage.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CURRENT_CREDENTIAL_STORAGE_VERSION,
|
||||
CredentialMigrationError,
|
||||
MissingSecretReferenceError,
|
||||
assertSettingsCredentialsAreReferences,
|
||||
hydrateWallpaperApiConfig,
|
||||
isCredentialParameterKey,
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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("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);
|
||||
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);
|
||||
});
|
||||
|
||||
void test("the persistence guard rejects credential-like custom query parameters", () => {
|
||||
assert.throws(
|
||||
() => assertSettingsCredentialsAreReferences(customCredentialSettings(), () => []),
|
||||
/plaintext credentials/u
|
||||
);
|
||||
});
|
||||
93
tests/drag-sort.test.ts
Normal file
93
tests/drag-sort.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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,
|
||||
setItems?: (items: Item[]) => void
|
||||
): DragSort<Item> {
|
||||
return new DragSort<Item>({
|
||||
container: {} as HTMLElement,
|
||||
items,
|
||||
getItemId: (item) => item.id,
|
||||
onReorder,
|
||||
setItems,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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"]
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -38,3 +39,13 @@ void test("API configuration tests fail closed and always delete their temporary
|
|||
assert.match(modal, /if \(!success\)/u);
|
||||
assert.match(modal, /finally \{[\s\S]*await apiManager\.deleteApi\(testApiId\)/u);
|
||||
});
|
||||
|
||||
void test("restarted background schedules reject stale timer callbacks", () => {
|
||||
const manager = readFileSync("src/core/background-manager.ts", "utf8");
|
||||
|
||||
assert.match(manager, /private scheduleGeneration = 0/u);
|
||||
assert.match(manager, /stop\(\): void \{[\s\S]*this\.scheduleGeneration \+= 1/u);
|
||||
assert.match(manager, /const scheduleGeneration = this\.scheduleGeneration/u);
|
||||
assert.match(manager, /if \(!this\.isCurrentSchedule\(scheduleGeneration\)\) return;/u);
|
||||
assert.match(manager, /if \(!this\.isCurrentSchedule\(generation\)\) return;/u);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,16 +33,32 @@ void test("image and response bounds fail closed", () => {
|
|||
});
|
||||
|
||||
void test("log sanitization removes query and header credentials", () => {
|
||||
const text = redactSensitiveText("https://api.example.test?q=sky&client_id=secret-token&key=second");
|
||||
assert.doesNotMatch(text, /secret-token|second/u);
|
||||
const text = redactSensitiveText(
|
||||
"https://api.example.test?q=sky&client_id=secret-token&access_token=second&password=third#x-api-key=fifth Basic fourth"
|
||||
);
|
||||
assert.doesNotMatch(text, /secret-token|second|third|fourth|fifth/u);
|
||||
assert.deepEqual(
|
||||
sanitizeForLog({
|
||||
Authorization: "Bearer secret",
|
||||
nested: { api_key: "hidden", query: "safe" },
|
||||
nested: {
|
||||
accessKey: "hidden-access-key",
|
||||
api_key: "hidden",
|
||||
bearerToken: "hidden-token",
|
||||
clientSecret: "hidden-client-secret",
|
||||
password: "also-hidden",
|
||||
query: "safe",
|
||||
},
|
||||
}),
|
||||
{
|
||||
Authorization: "[REDACTED]",
|
||||
nested: { api_key: "[REDACTED]", query: "safe" },
|
||||
nested: {
|
||||
accessKey: "[REDACTED]",
|
||||
api_key: "[REDACTED]",
|
||||
bearerToken: "[REDACTED]",
|
||||
clientSecret: "[REDACTED]",
|
||||
password: "[REDACTED]",
|
||||
query: "safe",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,24 +1,51 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
void test("release CI installs the lockfile and runs the shared gate first", () => {
|
||||
void test("release CI is tag-triggered and publishes verified changelog notes", () => {
|
||||
const workflow = readFileSync(".github/workflows/release.yml", "utf8");
|
||||
const evaluator = readFileSync("tools/quality/evaluate.mjs", "utf8");
|
||||
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||
allowScripts?: Record<string, boolean>;
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
|
||||
assert.match(workflow, /node-version: 22/u);
|
||||
assert.match(workflow, /tags:\s*\n\s*- "\[0-9\]\*\.\[0-9\]\*\.\[0-9\]\*"/u);
|
||||
assert.doesNotMatch(workflow, /branches:/u);
|
||||
assert.match(workflow, /run: npm ci/u);
|
||||
assert.match(workflow, /git cat-file -t/u);
|
||||
assert.match(workflow, /merge-base --is-ancestor/u);
|
||||
assert.match(workflow, /npm run release:verify/u);
|
||||
assert.match(workflow, /run: npm run check/u);
|
||||
assert.match(workflow, /attestations: write/u);
|
||||
assert.match(workflow, /id-token: write/u);
|
||||
assert.match(workflow, /uses: actions\/attest@v4/u);
|
||||
assert.match(workflow, /subject-path:\s*\|\s*main\.js\s*styles\.css/u);
|
||||
assert.match(workflow, /npm run release:notes/u);
|
||||
assert.match(workflow, /gh release create/u);
|
||||
assert.match(workflow, /gh release upload "\$RELEASE_TAG" main\.js manifest\.json styles\.css --clobber/u);
|
||||
assert.match(workflow, /--draft=false/u);
|
||||
assert.match(workflow, /main\.js,manifest\.json,styles\.css/u);
|
||||
assert.doesNotMatch(workflow, /semantic-release/iu);
|
||||
assert.doesNotMatch(workflow, /npm install/u);
|
||||
assert.equal(existsSync(".releaserc.yml"), false);
|
||||
assert.equal(packageJson.scripts?.check, "npm run build && npm run lint && npm run lint:css && npm test");
|
||||
assert.equal(packageJson.scripts?.["release:prepare"], "node tools/release/prepare.mjs");
|
||||
assert.equal(packageJson.scripts?.["release:verify"], "node tools/release/verify.mjs");
|
||||
assert.equal(packageJson.scripts?.["release:notes"], "node tools/release/notes.mjs");
|
||||
assert.deepEqual(packageJson.allowScripts, { "esbuild@0.28.1": true });
|
||||
assert.match(evaluator, /pass:\s*passed/u);
|
||||
assert.doesNotMatch(evaluator, /\n\s*passed,/u);
|
||||
});
|
||||
|
||||
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 packageLock = JSON.parse(readFileSync("package-lock.json", "utf8")) as {
|
||||
packages: Record<string, { version: string }>;
|
||||
version: string;
|
||||
};
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8")) as {
|
||||
|
|
@ -29,19 +56,31 @@ void test("version authorities and mobile compatibility stay aligned", () => {
|
|||
const versionSource = readFileSync("src/version.ts", "utf8");
|
||||
|
||||
assert.equal(packageJson.version, manifest.version);
|
||||
assert.equal(packageLock.version, manifest.version);
|
||||
assert.equal(packageLock.packages[""].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);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
assert.match(development, /`npm test`/u);
|
||||
assert.match(development, /`npm run check`/u);
|
||||
assert.match(development, /`npm run release:prepare -- <version>`/u);
|
||||
assert.match(development, /annotated tag/u);
|
||||
assert.match(agents, /Run `npm run check`/u);
|
||||
assert.match(agents, /`npm run release:prepare -- <version>`/u);
|
||||
assert.doesNotMatch(agents, /There is no automated test script/u);
|
||||
});
|
||||
|
||||
|
|
@ -49,7 +88,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);
|
||||
}
|
||||
|
|
|
|||
95
tests/release-tools.test.ts
Normal file
95
tests/release-tools.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
compareSemver,
|
||||
extractChangelogSection,
|
||||
parseSemver,
|
||||
prepareRelease,
|
||||
readVersionAuthorities,
|
||||
verifyRelease,
|
||||
writeReleaseNotes,
|
||||
} from "../tools/release/contract.mjs";
|
||||
|
||||
function createFixture(version = "1.2.3", notes = "* shipped safely"): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "dtb-release-tools-"));
|
||||
mkdirSync(join(root, "src"));
|
||||
writeFileSync(join(root, "manifest.json"), `${JSON.stringify({ id: "fixture", version }, null, "\t")}\n`, "utf8");
|
||||
writeFileSync(join(root, "package.json"), `${JSON.stringify({ name: "fixture", version }, null, 4)}\n`, "utf8");
|
||||
writeFileSync(
|
||||
join(root, "package-lock.json"),
|
||||
`${JSON.stringify({ lockfileVersion: 3, name: "fixture", packages: { "": { version } }, version }, null, 4)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
writeFileSync(join(root, "src", "version.ts"), `export const VERSION = "${version}";\n`, "utf8");
|
||||
writeFileSync(
|
||||
join(root, "CHANGELOG.md"),
|
||||
`## [${version}](https://example.test/${version}) (2026-07-21)\n\n${notes}\n\n## [1.2.2]\n\n* older\n`,
|
||||
"utf8"
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
void test("release versions use strict bare SemVer precedence", () => {
|
||||
assert.equal(parseSemver("1.2.3-beta.1").version, "1.2.3-beta.1");
|
||||
assert.equal(compareSemver("1.2.3-beta.1", "1.2.3-beta.2"), -1);
|
||||
assert.equal(compareSemver("1.2.3-beta.2", "1.2.3"), -1);
|
||||
assert.equal(compareSemver("9007199254740993.0.0", "9007199254740992.0.0"), 1);
|
||||
assert.equal(compareSemver("1.2.3+one", "1.2.3+two"), 0);
|
||||
assert.throws(() => parseSemver("v1.2.3"), /bare SemVer/u);
|
||||
assert.throws(() => parseSemver("1.2.3-beta.01"), /leading zeroes/u);
|
||||
});
|
||||
|
||||
void test("release preparation updates only aligned version authorities", () => {
|
||||
const root = createFixture();
|
||||
try {
|
||||
const paths = prepareRelease(root, "1.3.0");
|
||||
assert.equal(paths.length, 4);
|
||||
assert.equal(readVersionAuthorities(root).current, "1.3.0");
|
||||
assert.match(readFileSync(join(root, "manifest.json"), "utf8"), /\n\t"version": "1\.3\.0"/u);
|
||||
assert.throws(() => prepareRelease(root, "1.3.0"), /must be newer/u);
|
||||
assert.throws(() => prepareRelease(root, "1.2.9"), /must be newer/u);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test("release preparation fails before writing when authorities drift", () => {
|
||||
const root = createFixture();
|
||||
try {
|
||||
const packageBefore = readFileSync(join(root, "package.json"), "utf8");
|
||||
writeFileSync(join(root, "manifest.json"), '{"id":"fixture","version":"1.2.4"}\n', "utf8");
|
||||
assert.throws(() => prepareRelease(root, "1.3.0"), /not aligned/u);
|
||||
assert.equal(readFileSync(join(root, "package.json"), "utf8"), packageBefore);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test("release notes extract one exact non-empty changelog section", () => {
|
||||
const changelog =
|
||||
"## [2.0.0](https://example.test/2.0.0) (2026-07-21)\r\n\r\n### Features\r\n\r\n* added\r\n\r\n## [1.9.0]\r\n\r\n* older\r\n";
|
||||
assert.equal(
|
||||
extractChangelogSection(changelog, "2.0.0"),
|
||||
"## [2.0.0](https://example.test/2.0.0) (2026-07-21)\n\n### Features\n\n* added\n"
|
||||
);
|
||||
assert.throws(() => extractChangelogSection("## [2.0.0]\n\n## [1.9.0]\n\n* older\n", "2.0.0"), /no release notes/u);
|
||||
assert.throws(() => extractChangelogSection("## [1.9.0]\n\n* older\n", "2.0.0"), /exactly one/u);
|
||||
});
|
||||
|
||||
void test("release verification exports notes without overwriting", () => {
|
||||
const root = createFixture();
|
||||
const output = join(root, "release-notes.md");
|
||||
try {
|
||||
assert.match(verifyRelease(root, "1.2.3").notes, /shipped safely/u);
|
||||
const notes = writeReleaseNotes(root, "1.2.3", output);
|
||||
assert.equal(readFileSync(output, "utf8"), notes);
|
||||
assert.throws(() => writeReleaseNotes(root, "1.2.3", output), /EEXIST/u);
|
||||
assert.throws(() => verifyRelease(root, "1.2.4"), /does not match/u);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
33
tests/responsive-settings-contract.test.ts
Normal file
33
tests/responsive-settings-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";
|
||||
|
||||
const styles = readFileSync("styles.css", "utf8");
|
||||
|
||||
void test("constrained settings override the desktop card direction", () => {
|
||||
assert.match(
|
||||
styles,
|
||||
/@media \(max-width: 1100px\) \{\s*\.dtb-hint-section > \.setting-item,\s*\.dtb-large-button-container \.setting-item,\s*body \.dtb-item,\s*body \.dtb-section-container \.setting-item \{\s*flex-direction: column;\s*align-items: stretch;/u
|
||||
);
|
||||
assert.match(styles, /\.dtb-hint-section > \.setting-item,/u);
|
||||
assert.match(styles, /\.dtb-large-button-container \.setting-item,/u);
|
||||
assert.doesNotMatch(styles, /:has\(/u);
|
||||
assert.doesNotMatch(styles, /!important/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);
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { normalizeSettings } from "../src/core/settings";
|
||||
|
|
@ -6,6 +7,7 @@ import type { DTBSettings } from "../src/types";
|
|||
|
||||
function settingsFixture(): DTBSettings {
|
||||
return {
|
||||
credentialStorageVersion: 1,
|
||||
enabled: true,
|
||||
statusBarEnabled: true,
|
||||
blurDepth: 0,
|
||||
|
|
@ -95,3 +97,18 @@ void test("unknown and prototype-sensitive top-level fields are not retained", (
|
|||
assert.equal("polluted" in normalized, false);
|
||||
assert.equal(({} as { polluted?: boolean }).polluted, undefined);
|
||||
});
|
||||
|
||||
void test("interval settings cannot overflow the host timer delay", () => {
|
||||
const defaults = settingsFixture();
|
||||
|
||||
assert.equal(normalizeSettings({ intervalMinutes: -1 }, defaults).intervalMinutes, 60);
|
||||
assert.equal(normalizeSettings({ intervalMinutes: 35_791 }, defaults).intervalMinutes, 35_791);
|
||||
assert.equal(normalizeSettings({ intervalMinutes: 35_792 }, defaults).intervalMinutes, 60);
|
||||
});
|
||||
|
||||
void test("default settings are produced as isolated values", () => {
|
||||
const source = readFileSync("src/default-settings.ts", "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /let DEFAULT_SETTINGS/u);
|
||||
assert.match(source, /export function getDefaultSettings\(\): DTBSettings \{\s*return genDefaultSettings\(\);/u);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
28
tests/vault-access-contract.test.ts
Normal file
28
tests/vault-access-contract.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
function readTypeScriptTree(directory: string): string {
|
||||
return readdirSync(directory, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return [readTypeScriptTree(path)];
|
||||
return entry.isFile() && entry.name.endsWith(".ts") ? [readFileSync(path, "utf8")] : [];
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
void test("local image selection never enumerates every file in the vault", () => {
|
||||
const source = readTypeScriptTree("src");
|
||||
const folderSuggest = readFileSync("src/modals/image-folder-suggest-modal.ts", "utf8");
|
||||
const imageSuggest = readFileSync("src/modals/image-path-suggest-modal.ts", "utf8");
|
||||
const backgroundManagement = readFileSync("src/settings/sections/bg-management.ts", "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /\.vault\.(?:getFiles|getMarkdownFiles|getAllLoadedFiles|getAllFolders)\s*\(/u);
|
||||
assert.match(folderSuggest, /parent\.children/u);
|
||||
assert.match(folderSuggest, /getFolderByPath/u);
|
||||
assert.match(imageSuggest, /folder\.children/u);
|
||||
assert.match(imageSuggest, /getFolderByPath/u);
|
||||
assert.match(backgroundManagement, /folder\.children/u);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
|
@ -146,7 +146,7 @@ process.stdout.write(
|
|||
importantCount,
|
||||
transitionAllCount,
|
||||
},
|
||||
passed,
|
||||
pass: passed,
|
||||
score,
|
||||
scores,
|
||||
target: BASELINE.target,
|
||||
|
|
|
|||
212
tools/release/contract.mjs
Normal file
212
tools/release/contract.mjs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const SEMVER_PATTERN =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u;
|
||||
const VERSION_SOURCE_PATTERN = /export const VERSION = "([^"]+)";/gu;
|
||||
|
||||
function pathFor(root, ...parts) {
|
||||
return resolve(root, ...parts);
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
return { source, value: JSON.parse(source) };
|
||||
}
|
||||
|
||||
function serializeJson(source, value) {
|
||||
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
||||
const indent = source.match(/\r?\n([ \t]+)"/u)?.[1] ?? " ";
|
||||
return `${JSON.stringify(value, null, indent).replaceAll("\n", newline)}${newline}`;
|
||||
}
|
||||
|
||||
function readReleaseFiles(root) {
|
||||
const paths = {
|
||||
manifest: pathFor(root, "manifest.json"),
|
||||
packageJson: pathFor(root, "package.json"),
|
||||
packageLock: pathFor(root, "package-lock.json"),
|
||||
versionSource: pathFor(root, "src", "version.ts"),
|
||||
};
|
||||
const manifest = readJson(paths.manifest);
|
||||
const packageJson = readJson(paths.packageJson);
|
||||
const packageLock = readJson(paths.packageLock);
|
||||
const versionSource = readFileSync(paths.versionSource, "utf8");
|
||||
const versionMatches = [...versionSource.matchAll(VERSION_SOURCE_PATTERN)];
|
||||
|
||||
if (versionMatches.length !== 1) {
|
||||
throw new Error("src/version.ts must contain exactly one VERSION export");
|
||||
}
|
||||
if (typeof packageLock.value.packages?.[""]?.version !== "string") {
|
||||
throw new Error('package-lock.json must contain packages[""].version');
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
packageJson,
|
||||
packageLock,
|
||||
paths,
|
||||
versionSource,
|
||||
versions: {
|
||||
manifest: manifest.value.version,
|
||||
packageJson: packageJson.value.version,
|
||||
packageLock: packageLock.value.version,
|
||||
packageLockRoot: packageLock.value.packages[""].version,
|
||||
versionSource: versionMatches[0][1],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function alignedVersion(versions) {
|
||||
const entries = Object.entries(versions);
|
||||
if (entries.some(([, version]) => typeof version !== "string")) {
|
||||
throw new Error("Every version authority must contain a string version");
|
||||
}
|
||||
const unique = new Set(entries.map(([, version]) => version));
|
||||
if (unique.size !== 1) {
|
||||
const details = entries.map(([name, version]) => `${name}=${String(version)}`).join(", ");
|
||||
throw new Error(`Version authorities are not aligned: ${details}`);
|
||||
}
|
||||
return entries[0][1];
|
||||
}
|
||||
|
||||
export function parseSemver(version) {
|
||||
const match = SEMVER_PATTERN.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Expected a bare SemVer version, got: ${version}`);
|
||||
}
|
||||
const prerelease = match[4]?.split(".") ?? [];
|
||||
if (prerelease.some((part) => /^\d+$/u.test(part) && part.length > 1 && part.startsWith("0"))) {
|
||||
throw new Error(`Numeric prerelease identifiers cannot contain leading zeroes: ${version}`);
|
||||
}
|
||||
return {
|
||||
build: match[5]?.split(".") ?? [],
|
||||
core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
|
||||
prerelease,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareSemver(leftVersion, rightVersion) {
|
||||
const left = parseSemver(leftVersion);
|
||||
const right = parseSemver(rightVersion);
|
||||
for (let index = 0; index < left.core.length; index += 1) {
|
||||
if (left.core[index] !== right.core[index]) {
|
||||
return left.core[index] < right.core[index] ? -1 : 1;
|
||||
}
|
||||
}
|
||||
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
||||
if (left.prerelease.length === right.prerelease.length) return 0;
|
||||
return left.prerelease.length === 0 ? 1 : -1;
|
||||
}
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftPart = left.prerelease[index];
|
||||
const rightPart = right.prerelease[index];
|
||||
if (leftPart === undefined || rightPart === undefined) return leftPart === undefined ? -1 : 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
const leftNumeric = /^\d+$/u.test(leftPart);
|
||||
const rightNumeric = /^\d+$/u.test(rightPart);
|
||||
if (leftNumeric && rightNumeric) return BigInt(leftPart) < BigInt(rightPart) ? -1 : 1;
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
||||
return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function readVersionAuthorities(root = process.cwd()) {
|
||||
const files = readReleaseFiles(root);
|
||||
return { current: alignedVersion(files.versions), versions: files.versions };
|
||||
}
|
||||
|
||||
export function extractChangelogSection(changelog, version) {
|
||||
parseSemver(version);
|
||||
const normalized = changelog.replaceAll("\r\n", "\n");
|
||||
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
const headingPattern = new RegExp(`^## \\[${escapedVersion}\\].*$`, "gmu");
|
||||
const headings = [...normalized.matchAll(headingPattern)];
|
||||
if (headings.length !== 1) {
|
||||
throw new Error(`CHANGELOG.md must contain exactly one level-two [${version}] heading`);
|
||||
}
|
||||
const start = headings[0].index;
|
||||
const searchFrom = start + headings[0][0].length;
|
||||
const nextHeading = /^#{1,2}\s+(?:\[)?\d+\.\d+\.\d+/gmu;
|
||||
nextHeading.lastIndex = searchFrom;
|
||||
const next = nextHeading.exec(normalized);
|
||||
const section = normalized.slice(start, next?.index ?? normalized.length).trimEnd();
|
||||
const bodyStart = section.indexOf("\n");
|
||||
const body = bodyStart === -1 ? "" : section.slice(bodyStart + 1).trim();
|
||||
if (body.length === 0) {
|
||||
throw new Error(`CHANGELOG.md section ${version} has no release notes`);
|
||||
}
|
||||
return `${section}\n`;
|
||||
}
|
||||
|
||||
export function prepareRelease(root, targetVersion) {
|
||||
parseSemver(targetVersion);
|
||||
const files = readReleaseFiles(root);
|
||||
const currentVersion = alignedVersion(files.versions);
|
||||
parseSemver(currentVersion);
|
||||
if (compareSemver(targetVersion, currentVersion) <= 0) {
|
||||
throw new Error(`Target version ${targetVersion} must be newer than ${currentVersion}`);
|
||||
}
|
||||
|
||||
files.manifest.value.version = targetVersion;
|
||||
files.packageJson.value.version = targetVersion;
|
||||
files.packageLock.value.version = targetVersion;
|
||||
files.packageLock.value.packages[""].version = targetVersion;
|
||||
const nextVersionSource = files.versionSource.replace(
|
||||
VERSION_SOURCE_PATTERN,
|
||||
`export const VERSION = "${targetVersion}";`
|
||||
);
|
||||
const outputs = new Map([
|
||||
[files.paths.manifest, serializeJson(files.manifest.source, files.manifest.value)],
|
||||
[files.paths.packageJson, serializeJson(files.packageJson.source, files.packageJson.value)],
|
||||
[files.paths.packageLock, serializeJson(files.packageLock.source, files.packageLock.value)],
|
||||
[files.paths.versionSource, nextVersionSource],
|
||||
]);
|
||||
const originals = new Map([
|
||||
[files.paths.manifest, files.manifest.source],
|
||||
[files.paths.packageJson, files.packageJson.source],
|
||||
[files.paths.packageLock, files.packageLock.source],
|
||||
[files.paths.versionSource, files.versionSource],
|
||||
]);
|
||||
|
||||
try {
|
||||
for (const [path, content] of outputs) writeFileSync(path, content, "utf8");
|
||||
const prepared = readVersionAuthorities(root);
|
||||
if (prepared.current !== targetVersion)
|
||||
throw new Error("Prepared files did not converge on the target version");
|
||||
} catch (error) {
|
||||
const rollbackFailures = [];
|
||||
for (const [path, content] of originals) {
|
||||
try {
|
||||
writeFileSync(path, content, "utf8");
|
||||
} catch (rollbackError) {
|
||||
rollbackFailures.push(rollbackError);
|
||||
}
|
||||
}
|
||||
const suffix = rollbackFailures.length === 0 ? "changes were rolled back" : "rollback was incomplete";
|
||||
throw new Error(`Release preparation failed; ${suffix}`, { cause: error });
|
||||
}
|
||||
|
||||
return [...outputs.keys()];
|
||||
}
|
||||
|
||||
export function verifyRelease(root, version) {
|
||||
parseSemver(version);
|
||||
const authorities = readVersionAuthorities(root);
|
||||
if (authorities.current !== version) {
|
||||
throw new Error(`Release tag ${version} does not match version authorities (${authorities.current})`);
|
||||
}
|
||||
const changelog = readFileSync(pathFor(root, "CHANGELOG.md"), "utf8");
|
||||
return {
|
||||
authorities,
|
||||
notes: extractChangelogSection(changelog, version),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeReleaseNotes(root, version, outputPath) {
|
||||
const { notes } = verifyRelease(root, version);
|
||||
writeFileSync(outputPath, notes, { encoding: "utf8", flag: "wx" });
|
||||
return notes;
|
||||
}
|
||||
19
tools/release/notes.mjs
Normal file
19
tools/release/notes.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { writeReleaseNotes } from "./contract.mjs";
|
||||
|
||||
const usage = "Usage: npm run release:notes -- <version> <output-file>";
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
||||
process.stdout.write(`${usage}\n`);
|
||||
} else if (args.length !== 2) {
|
||||
process.stderr.write(`${usage}\n`);
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
try {
|
||||
writeReleaseNotes(process.cwd(), args[0], args[1]);
|
||||
process.stdout.write(`Wrote release notes for ${args[0]} to ${args[1]}.\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`release:notes: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
19
tools/release/prepare.mjs
Normal file
19
tools/release/prepare.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { prepareRelease } from "./contract.mjs";
|
||||
|
||||
const usage = "Usage: npm run release:prepare -- <version>";
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
||||
process.stdout.write(`${usage}\n`);
|
||||
} else if (args.length !== 1) {
|
||||
process.stderr.write(`${usage}\n`);
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
try {
|
||||
const paths = prepareRelease(process.cwd(), args[0]);
|
||||
process.stdout.write(`Prepared ${args[0]} in ${paths.length} version files.\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`release:prepare: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
19
tools/release/verify.mjs
Normal file
19
tools/release/verify.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { verifyRelease } from "./contract.mjs";
|
||||
|
||||
const usage = "Usage: npm run release:verify -- <version>";
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
||||
process.stdout.write(`${usage}\n`);
|
||||
} else if (args.length !== 1) {
|
||||
process.stderr.write(`${usage}\n`);
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
try {
|
||||
verifyRelease(process.cwd(), args[0]);
|
||||
process.stdout.write(`Verified release ${args[0]}.\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`release:verify: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,18 +4,13 @@
|
|||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"target": "ES2021",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
],
|
||||
"lib": ["DOM", "ES2021"],
|
||||
// other options
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
|
|
@ -23,4 +18,4 @@
|
|||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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