diff --git a/.github/workflows/release-package-to-npm.yml b/.github/workflows/release-package-to-npm.yml index fdb3557..ad9dda8 100644 --- a/.github/workflows/release-package-to-npm.yml +++ b/.github/workflows/release-package-to-npm.yml @@ -6,23 +6,13 @@ on: tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 16 - - run: npm ci - - run: npm test +permissions: + id-token: write # Required for OIDC + contents: read - publish-gpr: - needs: build +jobs: + publish: runs-on: ubuntu-latest - permissions: - packages: write - contents: read steps: - uses: actions/checkout@v4 - name: Update local package.json version from release tag @@ -33,9 +23,9 @@ jobs: ignore-semver-check: "false" # If set to "true", will not check if the version number is a valid semver version. - uses: actions/setup-node@v4 with: - node-version: 16 - registry-url: 'https://registry.npmjs.org' + node-version: "24" + registry-url: "https://registry.npmjs.org" - run: npm ci + - run: npm run build --if-present + - run: npm test - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b3bab72 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +变更日志 + +All notable changes to this project will be documented in this file. + +项目中的所有重要变更都会记录在此文件中。 + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +格式参考 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)。 + +## [2.1.6] - 2026-02-24 + +### Fixed / 修复 +- `fix(Storage)`: improve `removeItem` / `clear` behavior in Node.js environment; 完善 Node.js 环境下 `removeItem` / `clear` 方法(`47de721`)。 + +### Added / 新增 +- `feat(getStorage)`: export helper functions with type definitions and docs updates; 导出 `getStorage` 辅助函数并同步类型定义与文档(`ccd91f3`)。 + +### Changed / 变更 +- `refactor(getStorage)`: rename `string2array` to `value2array` and switch parsing logic to `switch`; 重命名 `string2array` 为 `value2array`,并将解析逻辑调整为 `switch` 语法(`4628920`)。 + +## [2.1.5] - 2026-02-21 + +### Changed / 变更 +- `chore(types)`: add local declarations for `@nsnanocat/util`; 新增本地类型声明并发布类型入口(`3071c12`)。 + +### Docs / 文档 +- `docs`: update changelog order and reflect export change; 更新 changelog 顺序并反映导出变更(`5a5994a`)。 + +## [2.1.4] - 2026-02-20 + +### Fixed / 修复 +- `fix(getStorage)`: Accept lowercase `boxjs` as alias for `BoxJs`; 支持小写别名 `boxjs`。(`45f5cd8`) +- `fix(getStorage)`: Treat undefined `$argument.Storage` the same as `PersistentStore`; 将未定义的 `.Storage` 视为 `PersistentStore` 并更新文档。(`99869a0`) + +### Docs / 文档 +- `docs(getStorage)`: Update README/JSDoc to mention undefined `.Storage` defaulting and adjust import example; 在 README/JSDoc 中说明当 `$argument.Storage` 未定义时视为 `PersistentStore`,并修正导入示例。 + +## [2.1.3] - 2026-02-20 + +### Docs / 文档 +- `docs(getStorage)`: clarify Settings/Configs/Caches merge; update JSDoc/README/CHANGELOG (commit 6bccb00)。 + +## [2.1.2] - 2026-02-20 + +### Fixed / 修复 +- `fix(argument)`: Normalize `globalThis.$argument` and guard `null`; 标准化 `globalThis.$argument` 并处理 `null` 场景(`c475e76`)。 +- `fix(getStorage)`: Include `$argument` in merge flow with conditional handling; 修复合并流程以包含 `$argument` 并增加条件控制(`3a1c8bb`)。 +- `fix(getStorage)`: Add merge source control by `$argument.Storage`; 支持通过 `$argument.Storage` 控制合并来源(`8a59892`)。 +- `fix(getStorage)`: Replace `BoxJs` merge source naming/usage with `PersistentStore`; 将合并来源命名/实现统一为 `PersistentStore`(`5fa69e4`)。 +- `fix(Storage)`: Add Surge `removeItem` support via `$persistentStore.write(null, keyName)`; 为 Surge 增加 `removeItem` 删除支持(`23ebecb`)。 + +### Changed / 变更 +- `refactor(getStorage)`: Rename `Store` to `Root` and align debug output; 重命名 `Store` 为 `Root` 并同步调试输出字段(`570a75c`)。 +- `refactor(getStorage)`: Centralize `Settings` merge controlled by `$argument.Storage`; ensure `Configs`/`Caches` are merged per-profile (`names`)(`17747ae`)。 +- `refactor(getStorage)`: switch to a default export instead of named; 改用默认导出(`export default`)。 + +### Docs / 文档 +- Sync README/JSDoc with recent behavior changes for `argument` / `getStorage` / `Storage`; 同步 `argument` / `getStorage` / `Storage` 的 README 与 JSDoc 说明(`2b13601`)。 +- `docs(getStorage)`: Document aliases for `$argument.Storage` (`Argument` / `$argument`, `PersistentStore` / `BoxJs` / `$persistentStore`) and correct merge-order in README/JSDoc; 为 `$argument.Storage` 增加别名说明并修正 README 中的合并顺序说明。 +- `docs(getStorage)`: Update import example and JSDoc to reflect default export; 更新导入示例及 JSDoc 以反映默认导出变更。 + +## [2.1.1] - 2026-02-20 + +### Changed / 变更 +- `refactor(getStorage)`: remove default export and clarify usage boundaries; 改为无默认导出并补充使用边界说明(`4105cf2`)。 +- `feat(index)`: re-export `getStorage` from entry point and update docs/tests; 从入口导出并更新文档/测试(`b0a1bd9`)。 + +### Docs / 文档 +- `docs`: improve installation/update guidance for newcomers; 优化安装与更新指引面向新手(`ce7c81a`)。 +- `docs`: enhance polyfill descriptions and links; 完善 polyfill 文档说明与引用链接(`b817c07`)。 +- `docs`: fill out README and JSDoc comments; 补充 README 与 JSDoc 注释说明(`5c5f1f3`)。 + + + + + +[2.1.6]: https://github.com/NSNanoCat/util/compare/v2.1.5...v2.1.6 +[2.1.5]: https://github.com/NSNanoCat/util/compare/v2.1.4...v2.1.5 +[2.1.4]: https://github.com/NSNanoCat/util/compare/v2.1.3...v2.1.4 +[2.1.3]: https://github.com/NSNanoCat/util/compare/v2.1.2...v2.1.3 +[2.1.2]: https://github.com/NSNanoCat/util/compare/v2.1.1...v2.1.2 +[2.1.1]: https://github.com/NSNanoCat/util/compare/v2.1.0...v2.1.1 + diff --git a/README.md b/README.md index d581ac4..4cc3632 100644 --- a/README.md +++ b/README.md @@ -1 +1,676 @@ -# utils \ No newline at end of file +# @nsnanocat/util + +用于统一 Quantumult X / Loon / Shadowrocket / Node.js / Egern / Surge / Stash 脚本接口的通用工具库。 + +核心目标: +- 统一不同平台的 HTTP、通知、持久化、结束脚本等调用方式。 +- 在一个脚本里尽量少写平台分支。 +- 提供一组可直接复用的 polyfill(`fetch` / `Storage` / `Console` / `Lodash`)。 + +## 目录 +- [安装与导入](#安装与导入) +- [导出清单](#导出清单) +- [模块依赖关系](#模块依赖关系) +- [API 参考(按 mjs 文件)](#api-参考按-mjs-文件) +- [平台差异总览](#平台差异总览) +- [已知限制与注意事项](#已知限制与注意事项) +- [参考资料](#参考资料) + +## 安装与导入 + +发布源: +- npm(推荐):[https://www.npmjs.com/package/@nsnanocat/util](https://www.npmjs.com/package/@nsnanocat/util) +- GitHub Packages(同步发布):[https://github.com/NSNanoCat/util/pkgs/npm/util](https://github.com/NSNanoCat/util/pkgs/npm/util) + +如果你不确定该选哪个,直接用 npm 源即可。 +如果你从 GitHub Packages 安装,需要先配置 GitHub 认证(PAT Token)。 + +### 1) 使用 npm 源(推荐,最省事) + +```bash +# 首次安装:拉取并安装这个包 +npm i @nsnanocat/util + +# 更新到最新版本:升级已安装的 util +npm i @nsnanocat/util@latest +# 你也可以使用 update(效果类似) +# npm update @nsnanocat/util +``` + +### 2) 使用 GitHub Packages 源(同步源,需要 GitHub 鉴权) + +```bash +# 把 @nsnanocat 作用域的包下载源切到 GitHub Packages +npm config set @nsnanocat:registry https://npm.pkg.github.com + +# 配置 GitHub Token(用于下载 GitHub Packages) +# 建议把 YOUR_GITHUB_PAT 换成你的真实 Token,再执行 +# echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_PAT" >> ~/.npmrc + +# 首次安装:从 GitHub Packages 安装 util +npm i @nsnanocat/util + +# 更新到最新版本:从 GitHub Packages 拉取最新 util +npm i @nsnanocat/util@latest +``` + +```js +import { + $app, // 当前平台名(如 "Surge" / "Loon" / "Quantumult X" / "Node.js") + $argument, // 已标准化的模块参数对象(导入包时自动处理字符串 -> 对象) + done, // 统一结束脚本函数(内部自动适配各平台 $done 差异) + fetch, // 统一 HTTP 请求函数(内部自动适配 $httpClient / $task / Node fetch) + notification, // 统一通知函数(内部自动适配 $notify / $notification.post) + time, // 时间格式化工具 + wait, // 延时等待工具(Promise) + Console, // 统一日志工具(支持 logLevel) + Lodash as _, // Lodash 建议按官方示例惯例使用 `_` 作为工具对象别名 + Storage, // 统一持久化存储接口(适配 $prefs / $persistentStore / 文件) +} from "@nsnanocat/util"; +``` + +## 导出清单 + +### 包主入口(`index.js`)已导出 +- `lib/app.mjs` +- `lib/argument.mjs`(`$argument` 参数标准化模块,导入时自动执行) +- `lib/done.mjs` +- `lib/notification.mjs` +- `lib/time.mjs` +- `lib/wait.mjs` +- `polyfill/Console.mjs` +- `polyfill/fetch.mjs` +- `polyfill/Lodash.mjs` +- `polyfill/StatusTexts.mjs` +- `polyfill/Storage.mjs` + +### 仓库中存在但未从主入口导出 +- `lib/environment.mjs` +- `lib/runScript.mjs` +- `getStorage.mjs`(薯条项目自用,仅当你的存储结构与薯条项目一致时再使用;请通过子路径 `@nsnanocat/util/getStorage.mjs` 导入) + +## 模块依赖关系 + +说明: +- 下表只描述“模块之间”的依赖关系、调用到的函数/常量、以及依赖原因。 +- 你在业务脚本中通常只需要调用对外 API;底层跨平台差异已在这些依赖链里处理。 + +| 模块 | 依赖模块 | 使用的函数/常量 | 为什么依赖 | +| --- | --- | --- | --- | +| `lib/app.mjs` | 无 | 无 | 核心平台识别源头,供其他差异模块分流 | +| `lib/environment.mjs` | `lib/app.mjs` | `$app` | 按平台生成统一 `$environment`(尤其补齐 `app` 字段) | +| `lib/argument.mjs` | `polyfill/Console.mjs`, `polyfill/Lodash.mjs` | `Console.debug`, `Console.logLevel`, `Lodash.set` | 统一 `$argument` 结构并支持深路径写入 | +| `lib/done.mjs` | `lib/app.mjs`, `polyfill/Console.mjs`, `polyfill/Lodash.mjs`, `polyfill/StatusTexts.mjs` | `$app`, `Console.log`, `Lodash.set`, `Lodash.pick`, `StatusTexts` | 将各平台 `$done` 参数格式拉平并兼容状态码/策略字段 | +| `lib/notification.mjs` | `lib/app.mjs`, `polyfill/Console.mjs` | `$app`, `Console.group`, `Console.log`, `Console.groupEnd`, `Console.error` | 将通知参数映射到各平台通知接口并统一日志输出 | +| `lib/runScript.mjs` | `polyfill/Console.mjs`, `polyfill/fetch.mjs`, `polyfill/Storage.mjs`, `polyfill/Lodash.mjs` | `Console.error`, `fetch`, `Storage.getItem`(`Lodash` 当前版本未实际调用) | 读取 BoxJS 配置并发起统一 HTTP 调用执行脚本 | +| `getStorage.mjs` | `lib/argument.mjs`, `polyfill/Console.mjs`, `polyfill/Lodash.mjs`, `polyfill/Storage.mjs` | `Console.debug`, `Console.logLevel`, `Lodash.merge`, `Storage.getItem` | 先标准化 `$argument`,再合并默认配置/持久化配置/运行参数 | +| `polyfill/Console.mjs` | `lib/app.mjs` | `$app` | 日志在 Node.js 与 iOS 脚本环境使用不同错误输出策略 | +| `polyfill/fetch.mjs` | `lib/app.mjs`, `polyfill/Lodash.mjs`, `polyfill/StatusTexts.mjs`, `polyfill/Console.mjs` | `$app`, `Lodash.set`, `StatusTexts`(`Console` 当前版本未实际调用) | 按平台选请求引擎并做参数映射、响应结构统一 | +| `polyfill/Storage.mjs` | `lib/app.mjs`, `polyfill/Lodash.mjs` | `$app`, `Lodash.get`, `Lodash.set`, `Lodash.unset` | 按平台选持久化后端并支持 `@key.path` 读写 | +| `polyfill/Lodash.mjs` | 无 | 无 | 提供路径/合并等基础能力,被多个模块复用 | +| `polyfill/StatusTexts.mjs` | 无 | 无 | 提供 HTTP 状态文案,供 `fetch/done` 使用 | +| `index.js` / `lib/index.js` / `polyfill/index.js` | 多个模块 | `export *` | 聚合导出,不含业务逻辑 | + +## API 参考(按 mjs 文件) + +### `lib/app.mjs` 与 `lib/environment.mjs`(平台识别与环境) + +#### `$app` +- 类型:`"Quantumult X" | "Loon" | "Shadowrocket" | "Node.js" | "Egern" | "Surge" | "Stash" | undefined` +- 角色:核心模块。库内所有存在平台行为差异的模块都会先读取 `$app` 再分流(如 `done`、`notification`、`fetch`、`Storage`、`Console`、`environment`)。 +- 读取方式: + +```js +import { $app } from "@nsnanocat/util"; +const appName = $app; // 读取 $app,返回平台字符串 +console.log(appName); +``` + +- 识别顺序(`lib/app.mjs`): +1. 存在 `$task` -> `Quantumult X` +2. 存在 `$loon` -> `Loon` +3. 存在 `$rocket` -> `Shadowrocket` +4. 存在 `module` -> `Node.js` +5. 存在 `Egern` -> `Egern` +6. 存在 `$environment` 且有 `surge-version` -> `Surge` +7. 存在 `$environment` 且有 `stash-version` -> `Stash` + +#### `$environment` / `environment()` +- 路径:`lib/environment.mjs`(未从包主入口导出) +- 签名:`environment(): object` +- 调用方式: + +```js +import { $environment, environment } from "@nsnanocat/util/lib/environment.mjs"; +console.log($environment.app); // 统一平台名 +console.log(environment()); // 当前环境对象 +``` + +- 规则:会为已识别平台统一生成 `$environment.app = "平台名称"`。 + +| 平台 | 调用路径(读取来源) | 读取结果示例 | +| --- | --- | --- | +| Surge | 读取全局 `$environment`,再写入 `app` | `{ ..., "surge-version": "x", app: "Surge" }` | +| Stash | 读取全局 `$environment`,再写入 `app` | `{ ..., "stash-version": "x", app: "Stash" }` | +| Egern | 读取全局 `$environment`,再写入 `app` | `{ ..., app: "Egern" }` | +| Loon | 读取全局 `$loon` 字符串并拆分 | `{ device, ios, "loon-version", app: "Loon" }` | +| Quantumult X | 不读取额外环境字段,直接构造对象 | `{ app: "Quantumult X" }` | +| Node.js | 读取 `process.env` 并写入 `process.env.app` | `{ ..., app: "Node.js" }` | +| 其他 | 无 | `{}` | + +### `lib/argument.mjs`(`$argument` 参数标准化模块) + +此文件无显式导出;`import` 后立即执行。这是为了统一各平台 `$argument` 的输入差异。 + +#### 行为 +- 通过包入口导入(`import ... from "@nsnanocat/util"`)时会自动执行本模块。 +- JSCore 环境不支持 `await import`,请使用静态导入或直接走包入口导入。 +- 读取到的 `$argument` 会按 URL Params 样式格式化为对象,并支持深路径。 +- 你也可以通过 `import { $argument } from "@nsnanocat/util"` 读取当前已标准化的 `$argument` 快照。 +- 平台输入差异说明: + - Surge / Stash / Egern:脚本参数通常以字符串形式传入(如 `a=1&b=2`)。 + - Loon:支持字符串和对象两种 `$argument` 形式。 + - Quantumult X / Shadowrocket:不提供 `$argument`。 +- 当全局 `$argument` 为 `string`(如 `"a.b=1&x=2"`)时: + - 按 `&` / `=` 切分。 + - 去掉值中的双引号。 + - 使用点路径展开对象(`a.b=1 -> { a: { b: "1" } }`)。 +- 当全局 `$argument` 为 `object` 时: + - 将 key 当路径写回新对象(`{"a.b":"1"}` -> `{a:{b:"1"}}`)。 +- 当 `$argument` 为 `null` 或 `undefined`:会归一化为 `{}`。 +- 若 `$argument.LogLevel` 存在:同步到 `Console.logLevel`。 + +#### 用法 +```js +import { $argument } from "@nsnanocat/util"; + +// $argument = "mode=on&a.b=1"; // 示例入参,实际由模块参数注入 +console.log($argument); // { mode: "on", a: { b: "1" } } +``` + +### `lib/done.mjs` + +#### `done(object = {})` +- 签名:`done(object?: object): void` +- 作用:统一不同平台的脚本结束接口(`$done` / Node 退出)。 + +说明:下表描述的是各 App 原生接口差异与本库内部映射逻辑。调用方只需要按 `done` 的统一参数传值即可,不需要自己再写平台分支。 + +支持字段(输入): +- `status`: `number | string` +- `url`: `string` +- `headers`: `object` +- `body`: `string | ArrayBuffer | TypedArray` +- `bodyBytes`: `ArrayBuffer` +- `policy`: `string` + +平台行为差异: + +| 平台 | `policy` 处理 | `status` 处理 | `body/bodyBytes` 处理 | 最终行为 | +| --- | --- | --- | --- | --- | +| Surge | 写入 `headers.X-Surge-Policy` | 透传 | 透传 | `$done(object)` | +| Loon | `object.node = policy` | 透传 | 透传 | `$done(object)` | +| Stash | 写入 `headers.X-Stash-Selected-Proxy`(URL 编码) | 透传 | 透传 | `$done(object)` | +| Egern | 不转换 | 透传 | 透传 | `$done(object)` | +| Shadowrocket | 不转换 | 透传 | 透传 | `$done(object)` | +| Quantumult X | 写入 `opts.policy` | `number` 会转 `HTTP/1.1 200 OK` 字符串 | 仅保留 `status/url/headers/body/bodyBytes`;`ArrayBuffer/TypedArray` 转 `bodyBytes` | `$done(object)` | +| Node.js | 不适用 | 不适用 | 不适用 | `process.exit(1)` | + +不可用/差异点: +- `policy` 在 Egern / Shadowrocket 分支不做映射。 +- Quantumult X 会丢弃未在白名单内的字段。 +- Quantumult X 的 `status` 在部分场景要求完整状态行(如 `HTTP/1.1 200 OK`),本库会在传入数字状态码时自动拼接(依赖 `StatusTexts`)。 +- Node.js 不调用 `$done`,而是直接退出进程,且退出码固定为 `1`。 + +### `lib/notification.mjs` + +#### `notification(title, subtitle, body, content)` +- 签名: + - `title?: string` + - `subtitle?: string` + - `body?: string` + - `content?: string | number | boolean | object` +- 默认值:`title = "ℹ️ ${$app} 通知"` +- 作用:统一 `notify/notification` 参数格式并发送通知。 + +`content` 可用 key(对象形式): +- 跳转:`open` / `open-url` / `url` / `openUrl` +- 复制:`copy` / `update-pasteboard` / `updatePasteboard` +- 媒体:`media` / `media-url` / `mediaUrl` +- 其他:`auto-dismiss`、`sound`、`mime` + +平台映射: + +| 平台 | 调用接口 | 字符串 `content` 行为 | 对象字段支持 | +| --- | --- | --- | --- | +| Surge | `$notification.post` | `{ url: content }` | `open-url`/`clipboard` 动作、`media-url`、`media-base64`、`auto-dismiss`、`sound` | +| Stash | `$notification.post` | `{ url: content }` | 同 Surge 分支(是否全部展示取决于 Stash 支持) | +| Egern | `$notification.post` | `{ url: content }` | 同 Surge 分支(是否全部展示取决于 Egern 支持) | +| Shadowrocket | `$notification.post` | `{ openUrl: content }` | 走 Surge 分支的 action/url/text/media 字段 | +| Loon | `$notification.post` | `{ openUrl: content }` | `openUrl`、`mediaUrl`(仅 http/https) | +| Quantumult X | `$notify` | `{ "open-url": content }` | `open-url`、`media-url`(仅 http/https)、`update-pasteboard` | +| Node.js | 不发送通知(非 iOS App 环境) | 无 | 无 | + +不可用/差异点: +- `copy/update-pasteboard` 在 Loon 分支不会生效。 +- Loon / Quantumult X 对 `media` 仅接受网络 URL;Base64 媒体不会自动映射。 +- Node.js 不是 iOS App 脚本环境,不支持 iOS 通知行为;当前分支仅日志输出。 + +### `lib/time.mjs` + +#### `time(format, ts)` +- 签名:`time(format: string, ts?: number): string` +- `ts`:可选时间戳(传给 `new Date(ts)`)。 +- 支持占位符:`YY`、`yyyy`、`MM`、`dd`、`HH`、`mm`、`ss`、`sss`、`S`(季度)。 + +```js +time("yyyy-MM-dd HH:mm:ss.sss"); +time("yyyyMMddHHmmss", Date.now()); +``` + +注意:当前实现对每个 token 只替换一次(`String.replace` 非全局)。 + +### `lib/wait.mjs` + +#### `wait(delay = 1000)` +- 签名:`wait(delay?: number): Promise` +- 用法: + +```js +await wait(500); +``` + +### `lib/runScript.mjs`(未主入口导出) + +#### `runScript(script, runOpts)` +- 签名:`runScript(script: string, runOpts?: { timeout?: number }): Promise` +- 作用:通过 BoxJS `httpapi` 调用本地脚本执行接口:`/v1/scripting/evaluate`。 +- 读取存储键: + - `@chavy_boxjs_userCfgs.httpapi` + - `@chavy_boxjs_userCfgs.httpapi_timeout` +- 请求体: + - `script_text` + - `mock_type: "cron"` + - `timeout` + +示例: +```js +import { runScript } from "./lib/runScript.mjs"; +await runScript("$done({})", { timeout: 20 }); +``` + +注意: +- 依赖你本地已正确配置 `httpapi`(`password@host:port`)。 +- 函数不返回接口响应,仅在失败时 `Console.error`。 + +### `getStorage.mjs` + +⚠️ 注意:该模块主要为薯条项目的存储结构设计,不作为通用默认 API。 +仅当你的持久化结构与薯条项目一致时才建议使用。 + +#### `getStorage(key, names, database)` +- 签名: + - `key: string`(持久化主键) + - `names: string | string[]`(平台名/配置组名,可嵌套数组) + - `database: object`(默认数据库) +- 返回:`{ Settings, Configs, Caches }` + +合并顺序由 `$argument.Storage` 控制(持久化读取统一使用 `PersistentStore = Storage.getItem(key, {})`;支持别名): +- 可用值(大小写敏感):`undefined` | `Argument` | `$argument` | `PersistentStore` | `BoxJs` | `boxjs` | `$persistentStore` | `database` +1. `undefined`:`database[name]` -> `$argument` -> `PersistentStore[name]` +2. `Argument` / `$argument`:`database[name]` -> `PersistentStore[name]` -> `$argument` +3. `PersistentStore` / `BoxJs` / `$persistentStore`(默认):`database[name]` -> `PersistentStore[name]` +4. `database`:仅 `database[name]` + +注意:`Configs` 与 `Caches` 始终按每个 `name` 合并(与 `$argument.Storage` 无关)。 + +自动类型转换(`Root.Settings`): +- 字符串 `"true"/"false"` -> `boolean` +- 纯数字字符串 -> `number` +- 含逗号字符串 -> `array`,并尝试逐项转数字 + +示例: +```js +import getStorage from "@nsnanocat/util/getStorage.mjs"; + +const store = getStorage("@my_box", ["YouTube", "Global"], database); +``` + +#### 命名导出(辅助函数) + +`getStorage.mjs` 同时导出以下辅助函数: +- `traverseObject(o, c)`:深度遍历对象并替换叶子值 +- `string2number(string)`:将纯数字字符串转换为数字 +- `value2array(value)`:字符串按逗号拆分;数字/布尔值会被包装为单元素数组 + +示例: +```js +import getStorage, { + traverseObject, + string2number, + value2array, +} from "@nsnanocat/util/getStorage.mjs"; + +const store = getStorage("@my_box", ["YouTube", "Global"], database); +``` + +### `polyfill/fetch.mjs` + +`fetch` 是仿照 Web API `Window.fetch` 设计的跨平台适配实现: +- 参考文档:https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch +- 中文文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch +- 目标:尽量保持 Web `fetch` 调用习惯,同时补齐各平台扩展参数映射 + +#### `fetch(resource, options = {})` +- 签名:`fetch(resource: object | string, options?: object): Promise` +- 参数合并: + - `resource` 为对象:`{ ...options, ...resource }` + - `resource` 为字符串:`{ ...options, url: resource }` +- 默认方法:无 `method` 时,若有 `body/bodyBytes` -> `POST`,否则 `GET` +- 会删除 headers:`Host`、`:authority`、`Content-Length/content-length` +- `timeout` 规则: + - 缺省 -> `5`(秒) + - `> 500` 视为毫秒并转为秒 + +通用请求字段: +- `url` +- `method` +- `headers` +- `body` +- `bodyBytes` +- `timeout` +- `policy` +- `redirection` / `auto-redirect` + +说明:下表是各 App 原生 HTTP 接口的差异补充,以及本库 `fetch` 的内部映射方式。调用方使用统一入参即可。 + +平台行为差异: + +| 平台 | 请求发送接口 | `timeout` 单位 | `policy` 映射 | 重定向字段 | 二进制处理 | +| --- | --- | --- | --- | --- | --- | +| Surge | `$httpClient[method]` | 秒 | 无专门映射 | `auto-redirect` | `Accept` 命中二进制类型时设置 `binary-mode` | +| Loon | `$httpClient[method]` | 毫秒(内部乘 1000) | `node = policy` | `auto-redirect` | 同上 | +| Stash | `$httpClient[method]` | 秒 | `headers.X-Stash-Selected-Proxy` | `auto-redirect` | 同上 | +| Egern | `$httpClient[method]` | 秒 | 无专门映射 | `auto-redirect` | 同上 | +| Shadowrocket | `$httpClient[method]` | 秒 | `headers.X-Surge-Proxy` | `auto-redirect` | 同上 | +| Quantumult X | `$task.fetch` | 毫秒(内部乘 1000) | `opts.policy` | `opts.redirection` | `body(ArrayBuffer/TypedArray)` 转 `bodyBytes`;响应按 `Content-Type` 恢复到 `body` | +| Node.js | `fetch` + `fetch-cookie` | 毫秒(内部乘 1000) | 无 | `redirect: follow/manual` | 返回 `body`(UTF-8 string) + `bodyBytes`(ArrayBuffer) | + +返回对象(统一后)常见字段: +- `ok` +- `status` +- `statusCode` +- `statusText` +- `headers` +- `body` +- `bodyBytes` + +不可用/差异点: +- `policy` 在 Surge / Egern / Node.js 分支没有额外适配逻辑。 +- `redirection` 在部分平台会映射为 `auto-redirect` 或 `opts.redirection`。 +- Node.js 分支依赖 `globalThis.fetch` / `globalThis.fetchCookie` 或 `node-fetch` + `fetch-cookie`。 +- 返回结构是统一兼容结构,不等同于浏览器 `Response` 对象。 + +### `polyfill/Storage.mjs` + +`Storage` 是仿照 Web Storage 接口(`Storage`)设计的跨平台持久化适配器: +- 参考文档:https://developer.mozilla.org/en-US/docs/Web/API/Storage +- 中文文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Storage +- 目标:统一 VPN App 脚本环境中的持久化读写接口,并尽量贴近 Web Storage 行为 + +#### `Storage.getItem(keyName, defaultValue = null)` +- 支持普通 key:按平台读持久化。 +- 支持路径 key:`@root.path.to.key`。 + +#### `Storage.setItem(keyName, keyValue)` +- 普通 key:按平台写持久化。 +- 路径 key:`@root.path` 写入嵌套对象。 +- `keyValue` 为对象时自动 `JSON.stringify`。 + +#### `Storage.removeItem(keyName)` +- Quantumult X:可用(`$prefs.removeValueForKey`)。 +- Surge:通过 `$persistentStore.write(null, keyName)` 删除。 +- Node.js:可用(删除 `box.dat` 中对应 key 并落盘)。 +- Loon / Stash / Egern / Shadowrocket:返回 `false`。 + +#### `Storage.clear()` +- Quantumult X:可用(`$prefs.removeAllValues`)。 +- Node.js:可用(清空 `box.dat` 并落盘)。 +- 其他平台:返回 `false`。 + +#### Node.js 特性 +- 数据文件默认:`box.dat`。 +- 读取路径优先级:当前目录 -> `process.cwd()`。 + +与 Web Storage 的行为差异: +- 支持 `@key.path` 深路径读写(Web Storage 原生不支持)。 +- `removeItem/clear` 仅部分平台可用(目前为 Quantumult X、Node.js,以及 Surge 的 `removeItem`)。 +- `getItem` 会尝试 `JSON.parse`,`setItem` 写入对象会 `JSON.stringify`。 + +平台后端映射: + +| 平台 | 读写接口 | +| --- | --- | +| Surge / Loon / Stash / Egern / Shadowrocket | `$persistentStore.read/write` | +| Quantumult X | `$prefs.valueForKey/setValueForKey` | +| Node.js | 本地 `box.dat` | + +### `polyfill/Console.mjs` + +`Console` 是统一日志工具(静态类)。 + +#### 日志级别 +- `Console.logLevel` 可读写。 +- 支持:`OFF(0)` / `ERROR(1)` / `WARN(2)` / `INFO(3)` / `DEBUG(4)` / `ALL(5)`。 + +`logLevel` 用法示例: + +```js +import { Console } from "@nsnanocat/util"; + +Console.logLevel = "debug"; // 或 4 +Console.debug("debug message"); + +Console.logLevel = 2; // WARN +Console.info("won't print at WARN level"); +Console.warn("will print"); + +console.log(Console.logLevel); // "WARN" +``` + +#### 方法 +- `clear()` +- `count(label = "default")` +- `countReset(label = "default")` +- `debug(...msg)` +- `error(...msg)` +- `exception(...msg)` +- `group(label)` +- `groupEnd()` +- `info(...msg)` +- `log(...msg)` +- `time(label = "default")` +- `timeLog(label = "default")` +- `timeEnd(label = "default")` +- `warn(...msg)` + +参数与返回值: + +| 方法 | 参数 | 返回值 | 说明 | +| --- | --- | --- | --- | +| `clear()` | 无 | `void` | 当前实现为空函数 | +| `count(label)` | `label?: string` | `void` | 计数并输出 | +| `countReset(label)` | `label?: string` | `void` | 重置计数器 | +| `debug(...msg)` | `...msg: any[]` | `void` | 仅 `DEBUG/ALL` 级别输出 | +| `error(...msg)` | `...msg: any[]` | `void` | Node.js 优先输出 `stack` | +| `exception(...msg)` | `...msg: any[]` | `void` | `error` 别名 | +| `group(label)` | `label: string` | `void` | 压栈分组 | +| `groupEnd()` | 无 | `void` | 出栈分组 | +| `info(...msg)` | `...msg: any[]` | `void` | `INFO` 及以上 | +| `log(...msg)` | `...msg: any[]` | `void` | 通用日志 | +| `time(label)` | `label?: string` | `void` | 记录起始时间 | +| `timeLog(label)` | `label?: string` | `void` | 输出耗时 | +| `timeEnd(label)` | `label?: string` | `void` | 清除计时器 | +| `warn(...msg)` | `...msg: any[]` | `void` | `WARN` 及以上 | + +平台差异: +- Node.js 下 `error` 会优先打印 `Error.stack`。 +- 其他平台统一加前缀符号输出(`❌/⚠️/ℹ️/🅱️`)。 + +### `polyfill/Lodash.mjs` + +`Lodash` 为“部分方法的简化实现”,不是完整 Lodash。各方法语义可参考: +- https://www.lodashjs.com +- https://lodash.com + +导入约定(建议): +- 这是 lodash 官方示例中常见的惯例写法:使用 `_` 作为工具对象别名。 + +```js +import { Lodash as _ } from "@nsnanocat/util"; + +const data = {}; +_.set(data, "a.b", 1); +console.log(data); // { a: { b: 1 } } + +const value = _.get(data, "a.b", 0); +console.log(value); // 1 +``` + +示例对应的 lodash 官方文档页面: +- `set(object, path, value)` + - 官方文档:https://lodash.com/docs/#set + - 中文文档:https://www.lodashjs.com/docs/lodash.set +- `get(object, path, defaultValue)` + - 官方文档:https://lodash.com/docs/#get + - 中文文档:https://www.lodashjs.com/docs/lodash.get + +当前实现包含: +- `escape(string)` +- `unescape(string)` +- `toPath(value)` +- `get(object, path, defaultValue)` +- `set(object, path, value)` +- `unset(object, path)` +- `pick(object, paths)` +- `omit(object, paths)` +- `merge(object, ...sources)` + +对应 lodash 官方文档页面: +- `escape(string)` + - 官方文档:https://lodash.com/docs/#escape + - 中文文档:https://www.lodashjs.com/docs/lodash.escape +- `unescape(string)` + - 官方文档:https://lodash.com/docs/#unescape + - 中文文档:https://www.lodashjs.com/docs/lodash.unescape +- `toPath(value)` + - 官方文档:https://lodash.com/docs/#toPath + - 中文文档:https://www.lodashjs.com/docs/lodash.toPath +- `get(object, path, defaultValue)` + - 官方文档:https://lodash.com/docs/#get + - 中文文档:https://www.lodashjs.com/docs/lodash.get +- `set(object, path, value)` + - 官方文档:https://lodash.com/docs/#set + - 中文文档:https://www.lodashjs.com/docs/lodash.set +- `unset(object, path)` + - 官方文档:https://lodash.com/docs/#unset + - 中文文档:https://www.lodashjs.com/docs/lodash.unset +- `pick(object, paths)` + - 官方文档:https://lodash.com/docs/#pick + - 中文文档:https://www.lodashjs.com/docs/lodash.pick +- `omit(object, paths)` + - 官方文档:https://lodash.com/docs/#omit + - 中文文档:https://www.lodashjs.com/docs/lodash.omit +- `merge(object, ...sources)` + - 官方文档:https://lodash.com/docs/#merge + - 中文文档:https://www.lodashjs.com/docs/lodash.merge + +参数与返回值: + +| 方法 | 参数 | 返回值 | 说明 | +| --- | --- | --- | --- | +| `escape` | `string: string` | `string` | HTML 转义 | +| `unescape` | `string: string` | `string` | HTML 反转义 | +| `toPath` | `value: string` | `string[]` | `a[0].b` -> `['a','0','b']` | +| `get` | `object?: object, path?: string\\|string[], defaultValue?: any` | `any` | 路径读取 | +| `set` | `object: object, path: string\\|string[], value: any` | `object` | 路径写入(会创建中间层) | +| `unset` | `object?: object, path?: string\\|string[]` | `boolean` | 删除路径并返回结果 | +| `pick` | `object?: object, paths?: string\\|string[]` | `object` | 挑选 key(仅第一层) | +| `omit` | `object?: object, paths?: string\\|string[]` | `object` | 删除 key(会修改原对象) | +| `merge` | `object: object, ...sources: object[]` | `object` | 深合并(非完整 lodash 行为) | + +`merge` 行为(与 lodash 官方有差异): +- 深度合并 Plain Object。 +- Array 直接覆盖;空数组不覆盖已存在值。 +- Map/Set 支持同类型合并;空 Map/Set 不覆盖已存在值。 +- `undefined` 不覆盖,`null` 会覆盖。 +- 直接修改目标对象(mutates target)。 + +### `polyfill/StatusTexts.mjs` + +#### `StatusTexts` +- 类型:`Record` +- 内容:HTTP 状态码到状态文本映射(100~511 的常见码)。 +- 主要用途:给 Quantumult X 的 `$done` 状态行补全文本(如 `HTTP/1.1 200 OK`)。 +- 参考示例:https://github.com/crossutility/Quantumult-X/raw/refs/heads/master/sample-rewrite-response-header.js + +## 平台差异总览 + +说明:本节展示的是各平台原生脚本接口差异。实际在本库中,这些差异已由 `done`、`fetch`、`notification`、`Storage` 等模块做了统一适配。 + +| 能力 | Quantumult X | Loon | Surge | Stash | Egern | Shadowrocket | Node.js | +| --- | --- | --- | --- | --- | --- | --- | --- | +| HTTP 请求 | `$task.fetch` | `$httpClient` | `$httpClient` | `$httpClient` | `$httpClient` | `$httpClient` | `fetch` | +| 通知 | `$notify` | `$notification.post` | `$notification.post` | `$notification.post` | `$notification.post` | `$notification.post` | 无 | +| 持久化 | `$prefs` | `$persistentStore` | `$persistentStore` | `$persistentStore` | `$persistentStore` | `$persistentStore` | `box.dat` | +| 结束脚本 | `$done` | `$done` | `$done` | `$done` | `$done` | `$done` | `process.exit(1)` | +| `removeItem/clear` | 可用 | 不可用 | `removeItem` 可用 / `clear` 不可用 | 不可用 | 不可用 | 不可用 | 可用 | +| `policy` 注入(`fetch/done`) | `opts.policy` | `node` | `X-Surge-Policy`(done) | `X-Stash-Selected-Proxy` | 无专门映射 | `X-Surge-Proxy`(fetch) | 无 | + +## 已知限制与注意事项 + +- `lib/argument.mjs` 为 `$argument` 标准化模块,`import` 时会按规则重写全局 `$argument`。 +- `lib/done.mjs` 在 Node.js 固定 `process.exit(1)`。 +- `polyfill/fetch.mjs` 的超时保护使用了 `Promise.race`,但当前实现里请求 Promise 先被 `await`,可能导致超时行为与预期不完全一致。 +- `Storage.removeItem("@a.b")` 分支存在未声明变量写入风险;如要大量使用路径删除,建议先本地验证。 +- `lib/runScript.mjs` 未从包主入口导出,需要按文件路径直接导入。 + +## 参考资料 + +以下资料用于对齐不同平台 `$` API 语义;README 的“平台差异”优先以本仓库实现为准。 + +### Surge +- [Surge Manual - Scripting API](https://manual.nssurge.com/scripting/common.html) +- [Surge Manual - HTTP Client API](https://manual.nssurge.com/scripting/http-client.html) + +### Stash +- [Stash Docs - Scripting Overview](https://stash.wiki/scripting/overview/) +- [Stash Docs - API](https://stash.wiki/scripting/apis/) +- [Stash Docs - Rewrite Script](https://stash.wiki/scripting/rewrite-script/) + +### Loon +- [Loon Script](https://nsloon.app/Loon-Script) +- [Loon API](https://nsloon.app/Loon-API) + +### Quantumult X +- [crossutility/Quantumult-X - sample-task.js](https://raw.githubusercontent.com/crossutility/Quantumult-X/master/sample-task.js) +- [crossutility/Quantumult-X - sample-rewrite-with-script.js](https://raw.githubusercontent.com/crossutility/Quantumult-X/master/sample-rewrite-with-script.js) +- [crossutility/Quantumult-X - sample-fetch-opts-policy.js](https://raw.githubusercontent.com/crossutility/Quantumult-X/master/sample-fetch-opts-policy.js) +- [crossutility/Quantumult-X - sample-rewrite-response-header.js](https://github.com/crossutility/Quantumult-X/raw/refs/heads/master/sample-rewrite-response-header.js) + +### Node.js +- [Node.js Globals - fetch](https://nodejs.org/api/globals.html#fetch) + +### Web API / Lodash +- [MDN - Window.fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) +- [MDN(中文)- Window.fetch](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch) +- [MDN - Storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage) +- [MDN(中文)- Storage](https://developer.mozilla.org/zh-CN/docs/Web/API/Storage) +- [Lodash Docs](https://www.lodashjs.com) +- [lodash.com](https://lodash.com) + +### Egern / Shadowrocket +- [Egern Docs - Scriptings 配置](https://egernapp.com/docs/configuration/scriptings) +- [Shadowrocket 官方站点](https://www.shadowlaunch.com/) + +> 说明:Egern 与 Shadowrocket 暂未检索到等价于 Surge/Loon/Stash 的完整公开脚本 API 页面;相关差异说明以本库实际代码分支行为为准。 diff --git a/getStorage.mjs b/getStorage.mjs index f2f1528..2b1c921 100644 --- a/getStorage.mjs +++ b/getStorage.mjs @@ -1,98 +1,176 @@ +import "./lib/argument.mjs"; import { Console } from "./polyfill/Console.mjs"; import { Lodash as _ } from "./polyfill/Lodash.mjs"; import { Storage } from "./polyfill/Storage.mjs"; /** - * Get Storage Variables + * 存储配置读取与合并结果。 + * Merged storage result object. + * + * @typedef {object} StorageProfile + * @property {Record} Settings 运行设置 / Runtime settings. + * @property {Record} Configs 静态配置 / Static configs. + * @property {Record} Caches 缓存数据 / Runtime caches. + */ + +/** + * 读取并合并默认配置、持久化配置与 `$argument`。 + * Read and merge default config, persisted config and `$argument`. + * + * 注意:`Configs` 与 `Caches` 始终按每个 profile(`names`)合并;`Settings` 的合并顺序由 `$argument.Storage` 控制(支持别名)。 + * Note: `Configs` and `Caches` are always merged per-profile (`names`); the merge order for `Settings` is controlled by `$argument.Storage` (aliases supported). + * + * 合并来源与顺序由 `$argument.Storage` 控制(支持以下值 / 别名): + * Merge source order is controlled by `$argument.Storage` (accepted values / aliases): + * - `undefined`: `database[name]` -> `$argument` -> `PersistentStore[name]` + * - `Argument` / `$argument`: `database[name]` -> `PersistentStore[name]` -> `$argument` + * - `PersistentStore` / `BoxJs` / `boxjs` / `$persistentStore`(默认):`database[name]` -> `PersistentStore[name]` + * - `database`: 仅 `database[name]` + * + * 注意:字符串比较为精确匹配(区分大小写)。 + * + * @since 2.1.3 * @link https://github.com/NanoCat-Me/utils/blob/main/getStorage.mjs * @author VirgilClyne - * @param {string} key - Persistent Store Key - * @param {array | string} names - Platform Names - * @param {object} database - Default Database - * @return {object} { Settings, Caches, Configs } + * @param {string} key 持久化主键 / Persistent store key. + * @param {string|string[]|Array} names 目标配置名 / Target profile names. + * @param {Record} database 默认数据库 / Default database object. + * @returns {StorageProfile} + * + * @module getStorage + * @default */ -export function getStorage(key, names, database) { +export default function getStorage(key, names, database) { + if (database?.Default?.Settings?.LogLevel) Console.logLevel = database.Default.Settings.LogLevel; + Console.debug("☑️ getStorage"); names = [names].flat(Number.POSITIVE_INFINITY); - //Console.log("☑️ getStorage"); /***************** Default *****************/ - const Store = { Settings: database?.Default?.Settings || {}, Configs: database?.Default?.Configs || {}, Caches: {} }; - //Console.debug("Default", `Store.Settings类型: ${typeof Store.Settings}`, `Store.Settings: ${JSON.stringify(Store.Settings)}`); - /***************** Database *****************/ - names.forEach(name => { - Store.Settings = { ...Store.Settings, ...database?.[name]?.Settings }; - Store.Configs = { ...Store.Configs, ...database?.[name]?.Configs }; - }); - //Console.debug("Database", `Store.Settings类型: ${typeof Store.Settings}`, `Store.Settings: ${JSON.stringify(Store.Settings)}`); - /***************** Argument *****************/ - switch (typeof $argument) { - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "string": - $argument = Object.fromEntries($argument.split("&").map(item => item.split("=", 2).map(i => i.replace(/\"/g, "")))); - case "object": { - const argument = {}; - Object.keys($argument).forEach(key => _.set(argument, key, $argument[key])); - //Console.debug(`✅ $argument`, `argument: ${JSON.stringify(argument)}`); - Store.Settings = { ...Store.Settings, ...argument }; - break; - } - case "undefined": - break; - } - //Console.debug("$argument", `Store.Settings类型: ${typeof Store.Settings}`, `Store.Settings: ${JSON.stringify(Store.Settings)}`); - /***************** BoxJs *****************/ + const Root = { Settings: database?.Default?.Settings || {}, Configs: database?.Default?.Configs || {}, Caches: {} }; + Console.debug("Default", `Root.Settings类型: ${typeof Root.Settings}`, `Root.Settings: ${JSON.stringify(Root.Settings)}`); + /***************** PersistentStore *****************/ // 包装为局部变量,用完释放内存 - // BoxJs的清空操作返回假值空字符串, 逻辑或操作符会在左侧操作数为假值时返回右侧操作数。 - const BoxJs = Storage.getItem(key); - if (BoxJs) { - //Console.debug("BoxJs", `BoxJs类型: ${typeof BoxJs}`, `BoxJs内容: ${JSON.stringify(BoxJs || {})}`); + // BoxJs 的清空操作返回假值空字符串, 逻辑或操作符会在左侧操作数为假值时返回右侧操作数。 + const PersistentStore = Storage.getItem(key, {}); + if (PersistentStore) { + Console.debug("☑️ PersistentStore", `PersistentStore类型: ${typeof PersistentStore}`, `PersistentStore内容: ${JSON.stringify(PersistentStore || {})}`); names.forEach(name => { - switch (typeof BoxJs?.[name]?.Settings) { - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "string": - BoxJs[name].Settings = JSON.parse(BoxJs[name].Settings || "{}"); - case "object": - Store.Settings = { ...Store.Settings, ...BoxJs[name].Settings }; - break; - case "undefined": - break; + if (typeof PersistentStore?.[name]?.Settings === "string") { + PersistentStore[name].Settings = JSON.parse(PersistentStore[name].Settings || "{}"); } - switch (typeof BoxJs?.[name]?.Caches) { - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "string": - BoxJs[name].Caches = JSON.parse(BoxJs[name].Caches || "{}"); - case "object": - Store.Caches = { ...Store.Caches, ...BoxJs[name].Caches }; - break; - case "undefined": - break; + if (typeof PersistentStore?.[name]?.Caches === "string") { + PersistentStore[name].Caches = JSON.parse(PersistentStore[name].Caches || "{}"); } }); - //Console.debug("BoxJs", `Store.Settings类型: ${typeof Store.Settings}`, `Store.Settings: ${JSON.stringify(Store.Settings)}`); + if (PersistentStore.LogLevel) Console.logLevel = PersistentStore.LogLevel; + Console.debug("✅ PersistentStore", `Root.Settings类型: ${typeof Root.Settings}`, `Root.Settings: ${JSON.stringify(Root.Settings)}`); + } + /***************** Merge *****************/ + names.forEach(name => { + _.merge(Root.Configs, database?.[name]?.Configs); + _.merge(Root.Caches, PersistentStore?.[name]?.Caches); + }); + switch ($argument.Storage) { + case "Argument": + case "$argument": + names.forEach(name => { + _.merge(Root.Settings, database?.[name]?.Settings, PersistentStore?.[name]?.Settings); + }); + _.merge(Root.Settings, $argument); + break; + default: + case "BoxJs": + case "boxjs": + case "PersistentStore": + case "$persistentStore": + names.forEach(name => { + _.merge(Root.Settings, database?.[name]?.Settings, PersistentStore?.[name]?.Settings); + }); + break; + case "database": + names.forEach(name => { + _.merge(Root.Settings, database?.[name]?.Settings); + }); + break; + case undefined: + names.forEach(name => { + _.merge(Root.Settings, database?.[name]?.Settings); + }); + _.merge(Root.Settings, $argument); + names.forEach(name => { + _.merge(Root.Settings, PersistentStore?.[name]?.Settings); + }); + break; } + if (Root.Settings.LogLevel) Console.logLevel = Root.Settings.LogLevel; + Console.debug("✅ Merge", `Root.Settings类型: ${typeof Root.Settings}`, `Root.Settings: ${JSON.stringify(Root.Settings)}`); /***************** traverseObject *****************/ - traverseObject(Store.Settings, (key, value) => { - //Console.debug("☑️ traverseObject", `${key}: ${typeof value}`, `${key}: ${JSON.stringify(value)}`); - if (value === "true" || value === "false") - value = JSON.parse(value); // 字符串转Boolean - else if (typeof value === "string") { - if (value.includes(",")) - value = value.split(",").map(item => string2number(item)); // 字符串转数组转数字 - else value = string2number(value); // 字符串转数字 + traverseObject(Root.Settings, (key, value) => { + Console.debug("☑️ traverseObject", `${key}: ${typeof value}`, `${key}: ${JSON.stringify(value)}`); + switch (typeof value) { + case "string": + switch (value) { + case "true": + case "false": + case "[]": + value = JSON.parse(value); // 字符串转Boolean/空数组 + break; + default: + if (value.includes(",")) + value = value2array(value).map(item => string2number(item)); // 字符串转数组转数字 + else value = string2number(value); // 字符串转数字 + } + break; } return value; }); - //Console.debug("✅ traverseObject", `Store.Settings类型: ${typeof Store.Settings}`, `Store.Settings: ${JSON.stringify(Store.Settings)}`); - return Store; + Console.debug("✅ traverseObject", `Root.Settings类型: ${typeof Root.Settings}`, `Root.Settings: ${JSON.stringify(Root.Settings)}`); + Console.debug("✅ getStorage"); + return Root; } -function traverseObject(o, c) { +/** + * 深度遍历对象并用回调替换叶子值。 + * Deep-walk an object and replace leaf values using callback. + * + * @param {Record} o 目标对象 / Target object. + * @param {(key: string, value: any) => any} c 处理回调 / Transformer callback. + * @returns {Record} + */ +export function traverseObject(o, c) { for (const t in o) { const n = o[t]; o[t] = "object" === typeof n && null !== n ? traverseObject(n, c) : c(t, n); } return o; } -function string2number(string) { + +/** + * 将纯数字字符串转换为数字。 + * Convert integer-like string into number. + * + * @param {string} string 输入字符串 / Input string. + * @returns {string|number} + */ +export function string2number(string) { if (/^\d+$/.test(string)) string = Number.parseInt(string, 10); return string; } + +/** + * 将值包装为数组。 + * Split value into array. + * + * @param {string|number|boolean|string[]|null|undefined} value 输入值 / Input value. + * @returns {(string|number|boolean)[]} + */ +export function value2array(value) { + switch (typeof value) { + case "string": + return value.split(","); + case "number": + case "boolean": + return [value]; + default: + return value || []; + } +} diff --git a/index.js b/index.js index 84c68c3..3649ba6 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ export * from "./lib/app.mjs"; +export * from "./lib/argument.mjs"; export * from "./lib/done.mjs"; export * from "./lib/notification.mjs"; export * from "./lib/time.mjs"; @@ -8,4 +9,15 @@ export * from "./polyfill/fetch.mjs"; export * from "./polyfill/Lodash.mjs"; export * from "./polyfill/StatusTexts.mjs"; export * from "./polyfill/Storage.mjs"; -export * from "./getStorage.mjs"; + +/** + * 已标准化的 `$argument` 快照。 + * Normalized `$argument` snapshot. + */ +export const $argument = globalThis.$argument ?? {}; + +/** + * 兼容别名(建议优先使用 `$argument`)。 + * Backward-compatible alias (prefer `$argument`). + */ +export const argument = $argument; diff --git a/lib/app.mjs b/lib/app.mjs index d74211d..99110f1 100644 --- a/lib/app.mjs +++ b/lib/app.mjs @@ -1,7 +1,18 @@ /** - * Current app name + * 当前运行平台名称。 + * Current runtime platform name. * - * @type {("Quantumult X" | "Loon" | "Shadowrocket" | "Node.js" | "Egern" | "Surge" | "Stash")} + * 识别顺序: + * Detection order: + * 1) `$task` -> Quantumult X + * 2) `$loon` -> Loon + * 3) `$rocket` -> Shadowrocket + * 4) `module` -> Node.js + * 5) `Egern` -> Egern + * 6) `$environment["surge-version"]` -> Surge + * 7) `$environment["stash-version"]` -> Stash + * + * @type {("Quantumult X" | "Loon" | "Shadowrocket" | "Node.js" | "Egern" | "Surge" | "Stash" | undefined)} */ export const $app = (() => { const keys = Object.keys(globalThis); diff --git a/lib/argument.mjs b/lib/argument.mjs new file mode 100644 index 0000000..deb3a9c --- /dev/null +++ b/lib/argument.mjs @@ -0,0 +1,54 @@ +import { Console } from "../polyfill/Console.mjs"; +import { Lodash as _ } from "../polyfill/Lodash.mjs"; + +/** + * 统一 `$argument` 输入格式并展开深路径。 + * Normalize `$argument` input format and expand deep paths. + * + * 平台差异: + * Platform differences: + * - Surge / Stash / Egern 常见为字符串参数: `a=1&b=2` + * - Surge / Stash / Egern usually pass string args: `a=1&b=2` + * - Loon 支持字符串和对象两种形态 + * - Loon supports both string and object forms + * - Quantumult X / Shadowrocket 一般不提供 `$argument` + * - Quantumult X / Shadowrocket usually do not expose `$argument` + * + * 执行时机: + * Execution timing: + * - 该模块为即时执行模块,`import` 时立即处理全局 `$argument` + * - This module executes immediately and mutates global `$argument` on import + * + * 归一化规则补充: + * Normalization details: + * - 使用 `globalThis.$argument` 读写,避免运行环境下未声明变量引用问题 + * - Read/write via `globalThis.$argument` to avoid undeclared variable access + * - 当 `$argument` 为 `null` 或 `undefined` 时,会重置为 `{}` + * - When `$argument` is `null` or `undefined`, it is normalized to `{}` + */ +(() => { + Console.debug("☑️ $argument"); + switch (typeof globalThis.$argument) { + case "string": { + const argument = Object.fromEntries(globalThis.$argument.split("&").map(item => item.split("=", 2).map(i => i.replace(/\"/g, "")))); + globalThis.$argument = {}; + Object.keys(argument).forEach(key => _.set(globalThis.$argument, key, argument[key])); + break; + } + case "object": { + if (globalThis.$argument === null) { + globalThis.$argument = {}; + break; + } + const argument = {}; + Object.keys(globalThis.$argument).forEach(key => _.set(argument, key, globalThis.$argument[key])); + globalThis.$argument = argument; + break; + } + case "undefined": + globalThis.$argument = {}; + break; + } + if (globalThis.$argument.LogLevel) Console.logLevel = globalThis.$argument.LogLevel; + Console.debug("✅ $argument", `$argument: ${JSON.stringify(globalThis.$argument)}`); +})(); diff --git a/lib/done.mjs b/lib/done.mjs index 1bb5098..6d38453 100644 --- a/lib/done.mjs +++ b/lib/done.mjs @@ -4,10 +4,30 @@ import { Lodash as _ } from "../polyfill/Lodash.mjs"; import { StatusTexts } from "../polyfill/StatusTexts.mjs"; /** - * Complete the script execution + * `done` 的统一入参结构。 + * Unified `done` input payload. * - * @export - * @param {object} object + * @typedef {object} DonePayload + * @property {number|string} [status] 响应状态码或状态行 / Response status code or status line. + * @property {string} [url] 响应 URL / Response URL. + * @property {Record} [headers] 响应头 / Response headers. + * @property {string|ArrayBuffer|ArrayBufferView} [body] 响应体 / Response body. + * @property {ArrayBuffer} [bodyBytes] 二进制响应体 / Binary response body. + * @property {string} [policy] 指定策略名 / Preferred policy name. + */ + +/** + * 结束脚本执行并按平台转换参数。 + * Complete script execution with platform-specific parameter mapping. + * + * 说明: + * Notes: + * - 这是调用入口,平台原生 `$done` 差异在内部处理 + * - This is the call entry and native `$done` differences are handled internally + * - Node.js 不调用 `$done`,而是直接退出进程 + * - Node.js does not call `$done`; it exits the process directly + * + * @param {DonePayload} [object={}] 统一响应对象 / Unified response object. * @returns {void} */ export function done(object = {}) { diff --git a/lib/environment.mjs b/lib/environment.mjs index 5b70418..931e4f8 100644 --- a/lib/environment.mjs +++ b/lib/environment.mjs @@ -1,6 +1,28 @@ import { $app } from "./app.mjs"; +/** + * 标准化后的运行环境对象。 + * Normalized runtime environment object. + * + * - Surge/Stash/Egern: 基于全局 `$environment` 并补充 `app` + * - Surge/Stash/Egern: based on global `$environment` with `app` patched + * - Loon: 解析 `$loon` 得到设备与版本信息 + * - Loon: parse `$loon` into device/version fields + * - Quantumult X: 仅返回 `{ app: "Quantumult X" }` + * - Quantumult X: returns `{ app: "Quantumult X" }` only + * - Node.js: 复用 `process.env` 并写入 `process.env.app` + * - Node.js: reuses `process.env` and writes `process.env.app` + * + * @type {Record} + */ export const $environment = environment(); + +/** + * 获取标准化环境对象。 + * Build and return the normalized environment object. + * + * @returns {Record} + */ export function environment() { switch ($app) { case "Surge": diff --git a/lib/index.js b/lib/index.js index c73a06f..9c44600 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,18 @@ export * from "./app.mjs"; +export * from "./argument.mjs"; export * from "./done.mjs"; export * from "./notification.mjs"; export * from "./time.mjs"; export * from "./wait.mjs"; + +/** + * 已标准化的 `$argument` 快照。 + * Normalized `$argument` snapshot. + */ +export const $argument = globalThis.$argument ?? {}; + +/** + * 兼容别名(建议优先使用 `$argument`)。 + * Backward-compatible alias (prefer `$argument`). + */ +export const argument = $argument; diff --git a/lib/notification.mjs b/lib/notification.mjs index 940caad..5dd8e06 100644 --- a/lib/notification.mjs +++ b/lib/notification.mjs @@ -2,20 +2,41 @@ import { $app } from "./app.mjs"; import { Console } from "../polyfill/Console.mjs"; /** - * 系统通知 + * 通知内容扩展参数。 + * Extended notification content options. * - * > 通知参数: 同时支持 QuanX 和 Loon 两种格式, EnvJs根据运行环境自动转换, Surge 环境不支持多媒体通知 - * - * 示例: - * $.msg(title, subtitle, body, "twitter://") - * $.msg(title, subtitle, body, { "open-url": "twitter://", "media-url": "https://github.githubassets.com/images/modules/open_graph/github-mark.png" }) - * $.msg(title, subtitle, body, { "open-url": "https://bing.com", "media-url": "https://github.githubassets.com/images/modules/open_graph/github-mark.png" }) + * @typedef {object|string|number|boolean} NotificationContent + * @property {string} [open] 打开链接 / Open URL. + * @property {string} ["open-url"] 打开链接 (QuanX) / Open URL (QuanX). + * @property {string} [url] 打开链接 / Open URL. + * @property {string} [openUrl] 打开链接 (Loon/Shadowrocket) / Open URL (Loon/Shadowrocket). + * @property {string} [copy] 复制文本 / Copy text. + * @property {string} ["update-pasteboard"] 复制文本 (QuanX) / Copy text (QuanX). + * @property {string} [updatePasteboard] 复制文本 / Copy text. + * @property {string} [media] 媒体 URL 或 Base64 / Media URL or Base64. + * @property {string} ["media-url"] 媒体 URL / Media URL. + * @property {string} [mediaUrl] 媒体 URL / Media URL. + * @property {string} [mime] Base64 媒体 MIME / MIME type for Base64 media. + * @property {number} ["auto-dismiss"] 自动消失秒数 / Auto dismiss seconds. + * @property {string} [sound] 提示音 / Notification sound. + */ + +/** + * 发送系统通知并按平台适配参数格式。 + * Send system notification with platform-specific payload mapping. * - * @param {string} title 标题 - * @param {string} subtitle 副标题 - * @param {string} body 内容 - * @param {*} mutableContent 通知扩展字段 + * 说明: + * Notes: + * - iOS App 平台调用 `$notification.post` 或 `$notify` + * - iOS app platforms call `$notification.post` or `$notify` + * - Node.js 不支持 iOS 通知接口,仅输出日志 + * - Node.js does not support iOS notification APIs; it logs only * + * @param {string} [title=`ℹ️ ${$app} 通知`] 标题 / Title. + * @param {string} [subtitle=""] 副标题 / Subtitle. + * @param {string} [body=""] 内容 / Message body. + * @param {NotificationContent} [content={}] 扩展参数 / Extended content options. + * @returns {void} */ export function notification(title = `ℹ️ ${$app} 通知`, subtitle = "", body = "", content = {}) { const mutableContent = MutableContent(content); @@ -39,6 +60,14 @@ export function notification(title = `ℹ️ ${$app} 通知`, subtitle = "", bod Console.groupEnd(); } +/** + * 将统一通知参数转换为平台可识别字段。 + * Convert normalized content into platform-recognized fields. + * + * @private + * @param {NotificationContent} content 通知扩展参数 / Extended content options. + * @returns {Record} + */ const MutableContent = content => { const mutableContent = {}; switch (typeof content) { diff --git a/lib/runScript.mjs b/lib/runScript.mjs index ec7aeeb..720bd5d 100644 --- a/lib/runScript.mjs +++ b/lib/runScript.mjs @@ -3,6 +3,27 @@ import { fetch } from "../polyfill/fetch.mjs"; import { Lodash as _ } from "../polyfill/Lodash.mjs"; import { Storage } from "../polyfill/Storage.mjs"; +/** + * 远程脚本执行选项。 + * Remote script execution options. + * + * @typedef {object} RunScriptOptions + * @property {number} [timeout] 执行超时秒数 / Timeout in seconds. + */ + +/** + * 通过 BoxJS HTTP API 触发脚本执行。 + * Trigger script execution through BoxJS HTTP API. + * + * 依赖键: + * Required keys: + * - `@chavy_boxjs_userCfgs.httpapi` (`password@host:port`) + * - `@chavy_boxjs_userCfgs.httpapi_timeout` + * + * @param {string} script 脚本文本 / Script source text. + * @param {RunScriptOptions} [runOpts] 运行选项 / Runtime options. + * @returns {Promise} + */ export async function runScript(script, runOpts) { let httpapi = Storage.getItem("@chavy_boxjs_userCfgs.httpapi"); httpapi = httpapi?.replace?.(/\n/g, "")?.trim(); diff --git a/lib/time.mjs b/lib/time.mjs index 9272670..c4824d8 100644 --- a/lib/time.mjs +++ b/lib/time.mjs @@ -1,14 +1,14 @@ /** - * time - * 时间格式化 - * [version of ISO8601]{@link https://262.ecma-international.org/5.1/#sec-15.9.1.15} - * 示例:time("yyyy-MM-dd qq HH:mm:ss.S") YYYY-MM-DDTHH:mm:ss.sssZ - * :time("yyyyMMddHHmmssS") - * YY:年 MM:月 dd:日 S:季 HH:时 m:分 ss:秒 sss:毫秒 Z:时区 - * 其中y可选0-4位占位符、S可选0-1位占位符,其余可选0-2位占位符 - * @param {string} format 格式化参数 - * @param {number} ts 可选: 根据指定时间戳返回格式化日期 + * 按模板格式化时间字符串。 + * Format date/time into a template string. * + * 支持占位符: + * Supported tokens: + * - `YY`, `yyyy`, `MM`, `dd`, `HH`, `mm`, `ss`, `sss`, `S` + * + * @param {string} format 格式模板 / Format template. + * @param {number} [ts] 可选时间戳 / Optional timestamp. + * @returns {string} */ export function time(format, ts) { const date = ts ? new Date(ts) : new Date(); diff --git a/lib/wait.mjs b/lib/wait.mjs index 260e674..5a1a85e 100644 --- a/lib/wait.mjs +++ b/lib/wait.mjs @@ -1,9 +1,9 @@ /** - * wait + * 延时等待指定毫秒后继续执行。 + * Wait for the given milliseconds before continuing. * - * @export - * @param {number} [delay=1000] - * @returns {Promise} + * @param {number} [delay=1000] 延迟毫秒 / Delay in milliseconds. + * @returns {Promise} */ export function wait(delay = 1000) { return new Promise(resolve => setTimeout(resolve, delay)); diff --git a/package.json b/package.json index 61c084c..4a0e5c3 100644 --- a/package.json +++ b/package.json @@ -14,22 +14,31 @@ "license": "Apache-2.0", "bugs": "https://github.com/NSNanoCat/util/issues", "main": "index.js", + "types": "types/nsnanocat-util.d.ts", "type": "module", "scripts": { "tsc:build": "npx tsc", - "test": "exit 0" + "test": "node --test test/*.test.js", + "test:merge": "node --test test/Lodash.merge.test.js", + "test:argument": "node --test test/argument.test.js", + "deprecate": "npm deprecate -f '@nsnanocat/util@0.0.0-preview' \"this package has been deprecated\"" }, "repository": { "type": "git", - "url": "https://github.com/NSNanoCat/util.git" + "url": "git+https://github.com/NSNanoCat/util.git" }, "files": [ "index.js", "lib", "polyfill", - "getStorage.mjs" + "getStorage.mjs", + "types" ], "devDependencies": { "typescript": "^5.6.3" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" } } diff --git a/polyfill/Console.mjs b/polyfill/Console.mjs index 9affeb6..5e3b9f4 100644 --- a/polyfill/Console.mjs +++ b/polyfill/Console.mjs @@ -1,12 +1,41 @@ import { $app } from "../lib/app.mjs"; +/** + * 统一日志工具,兼容各脚本平台与 Node.js。 + * Unified logger compatible with script platforms and Node.js. + * + * logLevel 用法: + * logLevel usage: + * - 可读: `Console.logLevel` 返回 `OFF|ERROR|WARN|INFO|DEBUG|ALL` + * - Read: `Console.logLevel` returns `OFF|ERROR|WARN|INFO|DEBUG|ALL` + * - 可写: 数字 `0~5` 或字符串 `off/error/warn/info/debug/all` + * - Write: number `0~5` or string `off/error/warn/info/debug/all` + * + * @example + * Console.logLevel = "debug"; + * Console.debug("only shown when level >= DEBUG"); + * Console.logLevel = 2; // WARN + */ export class Console { static #counts = new Map([]); static #groups = []; static #times = new Map([]); + /** + * 清空控制台(当前为空实现)。 + * Clear console (currently a no-op). + * + * @returns {void} + */ static clear = () => {}; + /** + * 增加计数器并打印当前值。 + * Increment counter and print the current value. + * + * @param {string} [label="default"] 计数器名称 / Counter label. + * @returns {void} + */ static count = (label = "default") => { switch (Console.#counts.has(label)) { case true: @@ -19,6 +48,13 @@ export class Console { Console.log(`${label}: ${Console.#counts.get(label)}`); }; + /** + * 重置计数器。 + * Reset a counter. + * + * @param {string} [label="default"] 计数器名称 / Counter label. + * @returns {void} + */ static countReset = (label = "default") => { switch (Console.#counts.has(label)) { case true: @@ -31,12 +67,26 @@ export class Console { } }; + /** + * 输出调试日志。 + * Print debug logs. + * + * @param {...any} msg 日志内容 / Log messages. + * @returns {void} + */ static debug = (...msg) => { if (Console.#level < 4) return; msg = msg.map(m => `🅱️ ${m}`); Console.log(...msg); }; + /** + * 输出错误日志。 + * Print error logs. + * + * @param {...any} msg 日志内容 / Log messages. + * @returns {void} + */ static error(...msg) { if (Console.#level < 1) return; switch ($app) { @@ -56,12 +106,39 @@ export class Console { Console.log(...msg); } + /** + * `error` 的别名。 + * Alias of `error`. + * + * @param {...any} msg 日志内容 / Log messages. + * @returns {void} + */ static exception = (...msg) => Console.error(...msg); + /** + * 进入日志分组。 + * Enter a log group. + * + * @param {string} label 分组名 / Group label. + * @returns {number} + */ static group = label => Console.#groups.unshift(label); + /** + * 退出日志分组。 + * Exit the latest log group. + * + * @returns {*} + */ static groupEnd = () => Console.#groups.shift(); + /** + * 输出信息日志。 + * Print info logs. + * + * @param {...any} msg 日志内容 / Log messages. + * @returns {void} + */ static info(...msg) { if (Console.#level < 3) return; msg = msg.map(m => `ℹ️ ${m}`); @@ -70,6 +147,12 @@ export class Console { static #level = 3; + /** + * 获取日志级别文本。 + * Get current log level text. + * + * @returns {"OFF"|"ERROR"|"WARN"|"INFO"|"DEBUG"|"ALL"} + */ static get logLevel() { switch (Console.#level) { case 0: @@ -88,6 +171,12 @@ export class Console { } } + /** + * 设置日志级别。 + * Set current log level. + * + * @param {number|string} level 级别值 / Level value. + */ static set logLevel(level) { switch (typeof level) { case "string": @@ -130,6 +219,13 @@ export class Console { } } + /** + * 输出通用日志。 + * Print generic logs. + * + * @param {...any} msg 日志内容 / Log messages. + * @returns {void} + */ static log = (...msg) => { if (Console.#level === 0) return; msg = msg.map(log => { @@ -157,16 +253,44 @@ export class Console { console.log(msg.join("\n")); }; + /** + * 开始计时。 + * Start timer. + * + * @param {string} [label="default"] 计时器名称 / Timer label. + * @returns {Map} + */ static time = (label = "default") => Console.#times.set(label, Date.now()); + /** + * 结束计时并移除计时器。 + * End timer and remove it. + * + * @param {string} [label="default"] 计时器名称 / Timer label. + * @returns {boolean} + */ static timeEnd = (label = "default") => Console.#times.delete(label); + /** + * 输出当前计时器耗时。 + * Print elapsed time for a timer. + * + * @param {string} [label="default"] 计时器名称 / Timer label. + * @returns {void} + */ static timeLog = (label = "default") => { const time = Console.#times.get(label); if (time) Console.log(`${label}: ${Date.now() - time}ms`); else Console.warn(`Timer "${label}" doesn’t exist`); }; + /** + * 输出警告日志。 + * Print warning logs. + * + * @param {...any} msg 日志内容 / Log messages. + * @returns {void} + */ static warn(...msg) { if (Console.#level < 2) return; msg = msg.map(m => `⚠️ ${m}`); diff --git a/polyfill/Lodash.mjs b/polyfill/Lodash.mjs index 1e2f71e..1733af6 100644 --- a/polyfill/Lodash.mjs +++ b/polyfill/Lodash.mjs @@ -1,5 +1,32 @@ /* https://www.lodashjs.com */ +/** + * 轻量 Lodash 工具集。 + * Lightweight Lodash-like utilities. + * + * 说明: + * Notes: + * - 这是 Lodash 的“部分方法”简化实现,不等价于完整 Lodash + * - This is a simplified subset, not a full Lodash implementation + * - 各方法语义可参考 Lodash 官方文档 + * - Method semantics can be referenced from official Lodash docs + * - 导入时建议使用 `Lodash as _`,遵循 lodash 官方示例惯例 + * - Use `Lodash as _` when importing, following official lodash example convention + * + * 参考: + * Reference: + * - https://www.lodashjs.com + * - https://lodash.com + */ export class Lodash { + /** + * HTML 特殊字符转义。 + * Escape HTML special characters. + * + * @param {string} string 输入文本 / Input text. + * @returns {string} + * @see {@link https://lodash.com/docs/#escape lodash.escape} + * @see {@link https://www.lodashjs.com/docs/lodash.escape lodash.escape (中文)} + */ static escape(string) { const map = { "&": "&", @@ -11,6 +38,17 @@ export class Lodash { return string.replace(/[&<>"']/g, m => map[m]); } + /** + * 按路径读取对象值。 + * Get object value by path. + * + * @param {object} [object={}] 目标对象 / Target object. + * @param {string|string[]} [path=""] 路径 / Path. + * @param {*} [defaultValue=undefined] 默认值 / Default value. + * @returns {*} + * @see {@link https://lodash.com/docs/#get lodash.get} + * @see {@link https://www.lodashjs.com/docs/lodash.get lodash.get (中文)} + */ static get(object = {}, path = "", defaultValue = undefined) { // translate array case to dot case, then split with . // a[0].b -> a.0.b -> ['a', '0', 'b'] @@ -22,24 +60,162 @@ export class Lodash { return result === undefined ? defaultValue : result; } + /** + * 递归合并源对象的自身可枚举属性到目标对象 + * Recursively merge source enumerable properties into target object. + * @description 简化版 lodash.merge,用于合并配置对象 + * @description A simplified lodash.merge for config merging. + * + * 适用情况: + * - 合并嵌套的配置/设置对象 + * - 需要深度合并而非浅层覆盖的场景 + * - 多个源对象依次合并到目标对象 + * + * 限制: + * - 仅处理普通对象 (Plain Object),不处理 Date/RegExp 等特殊对象 + * - Map/Set 仅支持同类型合并,不递归内部值 + * - 数组会被直接覆盖,不会合并数组元素 + * - 不处理循环引用,可能导致栈溢出 + * - 不复制 Symbol 属性和不可枚举属性 + * - 不保留原型链,仅处理自身属性 + * - 会修改原始目标对象 (mutates target) + * + * @param {object} object - 目标对象 + * @param {object} object - Target object. + * @param {...object} sources - 源对象(可多个) + * @param {...object} sources - Source objects. + * @returns {object} 返回合并后的目标对象 + * @returns {object} Merged target object. + * @see {@link https://lodash.com/docs/#merge lodash.merge} + * @see {@link https://www.lodashjs.com/docs/lodash.merge lodash.merge (中文)} + * @example + * const target = { a: { b: 1 }, c: 2 }; + * const source = { a: { d: 3 }, e: 4 }; + * Lodash.merge(target, source); + * // => { a: { b: 1, d: 3 }, c: 2, e: 4 } + */ + static merge(object, ...sources) { + if (object === null || object === undefined) return object; + + for (const source of sources) { + if (source === null || source === undefined) continue; + + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = object[key]; + + switch (true) { + case Lodash.#isPlainObject(sourceValue) && Lodash.#isPlainObject(targetValue): + // 递归合并对象 + object[key] = Lodash.merge(targetValue, sourceValue); + break; + case sourceValue instanceof Map && targetValue instanceof Map: + // 合并 Map(空 Map 跳过) + if (sourceValue.size > 0) { + for (const [k, v] of sourceValue) { + targetValue.set(k, v); + } + } + break; + case sourceValue instanceof Set && targetValue instanceof Set: + // 合并 Set(空 Set 跳过) + if (sourceValue.size > 0) { + for (const v of sourceValue) { + targetValue.add(v); + } + } + break; + case Array.isArray(sourceValue) && sourceValue.length === 0 && targetValue !== undefined: + // 空数组不覆盖已有值 + break; + case (sourceValue instanceof Map && sourceValue.size === 0 && targetValue !== undefined): + case (sourceValue instanceof Set && sourceValue.size === 0 && targetValue !== undefined): + // 空 Map/Set 不覆盖已有值 + break; + case sourceValue !== undefined: + object[key] = sourceValue; + break; + } + } + } + + return object; + } + + /** + * 判断值是否为普通对象 (Plain Object) + * Check whether a value is a plain object. + * @param {*} value - 要检查的值 + * @param {*} value - Value to check. + * @returns {boolean} 如果是普通对象返回 true + * @returns {boolean} Returns true when value is a plain object. + * @see {@link https://lodash.com/docs/#isPlainObject lodash.isPlainObject} + * @see {@link https://www.lodashjs.com/docs/lodash.isPlainObject lodash.isPlainObject (中文)} + */ + static #isPlainObject(value) { + if (value === null || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; + } + + /** + * 删除对象指定路径并返回对象。 + * Omit paths from object and return the same object. + * + * @param {object} [object={}] 目标对象 / Target object. + * @param {string|string[]} [paths=[]] 要删除的路径 / Paths to remove. + * @returns {object} + * @see {@link https://lodash.com/docs/#omit lodash.omit} + * @see {@link https://www.lodashjs.com/docs/lodash.omit lodash.omit (中文)} + */ static omit(object = {}, paths = []) { if (!Array.isArray(paths)) paths = [paths.toString()]; paths.forEach(path => Lodash.unset(object, path)); return object; } + /** + * 仅保留对象指定键(第一层)。 + * Pick selected keys from object (top level only). + * + * @param {object} [object={}] 目标对象 / Target object. + * @param {string|string[]} [paths=[]] 需要保留的键 / Keys to keep. + * @returns {object} + * @see {@link https://lodash.com/docs/#pick lodash.pick} + * @see {@link https://www.lodashjs.com/docs/lodash.pick lodash.pick (中文)} + */ static pick(object = {}, paths = []) { if (!Array.isArray(paths)) paths = [paths.toString()]; const filteredEntries = Object.entries(object).filter(([key, value]) => paths.includes(key)); return Object.fromEntries(filteredEntries); } + /** + * 按路径写入对象值。 + * Set object value by path. + * + * @param {object} object 目标对象 / Target object. + * @param {string|string[]} path 路径 / Path. + * @param {*} value 写入值 / Value. + * @returns {object} + * @see {@link https://lodash.com/docs/#set lodash.set} + * @see {@link https://www.lodashjs.com/docs/lodash.set lodash.set (中文)} + */ static set(object, path, value) { if (!Array.isArray(path)) path = Lodash.toPath(path); path.slice(0, -1).reduce((previousValue, currentValue, currentIndex) => (Object(previousValue[currentValue]) === previousValue[currentValue] ? previousValue[currentValue] : (previousValue[currentValue] = /^\d+$/.test(path[currentIndex + 1]) ? [] : {})), object)[path[path.length - 1]] = value; return object; } + /** + * 将点路径或数组下标路径转换为数组。 + * Convert dot/array-index path string into path segments. + * + * @param {string} value 路径字符串 / Path string. + * @returns {string[]} + * @see {@link https://lodash.com/docs/#toPath lodash.toPath} + * @see {@link https://www.lodashjs.com/docs/lodash.toPath lodash.toPath (中文)} + */ static toPath(value) { return value .replace(/\[(\d+)\]/g, ".$1") @@ -47,6 +223,15 @@ export class Lodash { .filter(Boolean); } + /** + * HTML 实体反转义。 + * Unescape HTML entities. + * + * @param {string} string 输入文本 / Input text. + * @returns {string} + * @see {@link https://lodash.com/docs/#unescape lodash.unescape} + * @see {@link https://www.lodashjs.com/docs/lodash.unescape lodash.unescape (中文)} + */ static unescape(string) { const map = { "&": "&", @@ -58,6 +243,16 @@ export class Lodash { return string.replace(/&|<|>|"|'/g, m => map[m]); } + /** + * 删除对象路径对应的值。 + * Remove value by object path. + * + * @param {object} [object={}] 目标对象 / Target object. + * @param {string|string[]} [path=""] 路径 / Path. + * @returns {boolean} + * @see {@link https://lodash.com/docs/#unset lodash.unset} + * @see {@link https://www.lodashjs.com/docs/lodash.unset lodash.unset (中文)} + */ static unset(object = {}, path = "") { if (!Array.isArray(path)) path = Lodash.toPath(path); const result = path.reduce((previousValue, currentValue, currentIndex) => { diff --git a/polyfill/StatusTexts.mjs b/polyfill/StatusTexts.mjs index 26a3d71..40fd8fa 100644 --- a/polyfill/StatusTexts.mjs +++ b/polyfill/StatusTexts.mjs @@ -1,3 +1,20 @@ +/** + * HTTP 状态码文本映射表。 + * HTTP status code to status text map. + * + * 主要用途: + * Primary usage: + * - 为 Quantumult X 的 `$done` 状态行拼接提供状态文本 + * - Provide status text for Quantumult X `$done` status-line composition + * - QX 在部分场景要求 `status` 为完整状态行(如 `HTTP/1.1 200 OK`) + * - QX may require full status line (e.g. `HTTP/1.1 200 OK`) in some cases + * + * 参考: + * Reference: + * - https://github.com/crossutility/Quantumult-X/raw/refs/heads/master/sample-rewrite-response-header.js + * + * @type {Record} + */ export const StatusTexts = { 100: "Continue", 101: "Switching Protocols", diff --git a/polyfill/Storage.mjs b/polyfill/Storage.mjs index 116d70a..238b1a4 100644 --- a/polyfill/Storage.mjs +++ b/polyfill/Storage.mjs @@ -2,36 +2,70 @@ import { $app } from "../lib/app.mjs"; import { Lodash as _ } from "./Lodash.mjs"; /** - * Storage + * 跨平台持久化存储适配器。 + * Cross-platform persistent storage adapter. * - * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Storage/setItem - * @export - * @class Storage - * @typedef {Storage} + * 设计目标: + * Design goal: + * - 仿照 Web Storage (`Storage`) 接口设计 + * - Modeled after Web Storage (`Storage`) interface + * - 统一 VPN App 脚本环境中的持久化读写接口 + * - Unify persistence APIs across VPN app script environments + * + * 支持后端: + * Supported backends: + * - Surge/Loon/Stash/Egern/Shadowrocket: `$persistentStore` + * - Quantumult X: `$prefs` + * - Node.js: 本地 `box.dat` + * - Node.js: local `box.dat` + * + * 支持路径键: + * Supports path key: + * - `@root.path.to.value` + * + * 与 Web Storage 的已知差异: + * Known differences from Web Storage: + * - 支持 `@key.path` 深路径读写(Web Storage 原生不支持) + * - Supports `@key.path` deep-path access (not native in Web Storage) + * - `removeItem/clear` 并非所有平台都可用 + * - `removeItem/clear` are not available on every platform + * - 读取时会尝试 `JSON.parse`,写入对象会 `JSON.stringify` + * - Reads try `JSON.parse`, writes stringify objects + * + * @link https://developer.mozilla.org/en-US/docs/Web/API/Storage + * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Storage */ export class Storage { /** - * data + * Node.js 环境下的内存数据缓存。 + * In-memory data cache for Node.js runtime. * - * @static - * @type {file} + * @type {Record|null} */ static data = null; + + /** + * Node.js 持久化文件名。 + * Data file name used in Node.js. + * + * @type {string} + */ static dataFile = "box.dat"; + /** - * nameRegex + * `@key.path` 解析正则。 + * Regex for `@key.path` parsing. * - * @static - * @type {regexp} + * @type {RegExp} */ static #nameRegex = /^@(?[^.]+)(?:\.(?.*))?$/; /** - * getItem + * 读取存储值。 + * Read value from persistent storage. * - * @static - * @param {string} keyName - * @param {*} [defaultValue] + * @param {string} keyName 键名或路径键 / Key or path key. + * @param {*} [defaultValue=null] 默认值 / Default value when key is missing. * @returns {*} */ static getItem(keyName, defaultValue = null) { @@ -80,11 +114,11 @@ export class Storage { } /** - * setItem + * 写入存储值。 + * Write value into persistent storage. * - * @static - * @param {string} keyName - * @param {*} keyValue + * @param {string} keyName 键名或路径键 / Key or path key. + * @param {*} keyValue 写入值 / Value to store. * @returns {boolean} */ static setItem(keyName = new String(), keyValue = new String()) { @@ -135,10 +169,16 @@ export class Storage { } /** - * removeItem + * 删除存储值。 + * Remove value from persistent storage. * - * @static - * @param {string} keyName + * 平台说明: + * Platform notes: + * - Quantumult X: `$prefs.removeValueForKey` + * - Surge: 通过 `$persistentStore.write(null, keyName)` 删除 + * - 其余平台当前返回 `false` + * + * @param {string} keyName 键名或路径键 / Key or path key. * @returns {boolean} */ static removeItem(keyName) { @@ -156,6 +196,8 @@ export class Storage { default: switch ($app) { case "Surge": + result = $persistentStore.write(null, keyName); + break; case "Loon": case "Stash": case "Egern": @@ -166,7 +208,11 @@ export class Storage { result = $prefs.removeValueForKey(keyName); break; case "Node.js": - result = false; + // result = false; + Storage.data = Storage.#loaddata(Storage.dataFile); + delete Storage.data[keyName]; + Storage.#writedata(Storage.dataFile); + result = true; break; default: result = false; @@ -178,9 +224,9 @@ export class Storage { } /** - * clear + * 清空存储(仅 Quantumult X 支持)。 + * Clear storage (supported by Quantumult X only). * - * @static * @returns {boolean} */ static clear() { @@ -197,7 +243,11 @@ export class Storage { result = $prefs.removeAllValues(); break; case "Node.js": - result = false; + // result = false; + Storage.data = Storage.#loaddata(Storage.dataFile); + Storage.data = {}; + Storage.#writedata(Storage.dataFile); + result = true; break; default: result = false; @@ -207,10 +257,12 @@ export class Storage { } /** - * #loaddata + * 从 Node.js 数据文件加载 JSON。 + * Load JSON data from Node.js data file. * - * @param {string} dataFile - * @returns {*} + * @private + * @param {string} dataFile 数据文件名 / Data file name. + * @returns {Record} */ static #loaddata = dataFile => { if ($app === "Node.js") { @@ -232,9 +284,12 @@ export class Storage { }; /** - * #writedata + * 将内存数据写入 Node.js 数据文件。 + * Persist in-memory data to Node.js data file. * - * @param {string} [dataFile=this.dataFile] + * @private + * @param {string} [dataFile=this.dataFile] 数据文件名 / Data file name. + * @returns {void} */ static #writedata = (dataFile = this.dataFile) => { if ($app === "Node.js") { diff --git a/polyfill/fetch.mjs b/polyfill/fetch.mjs index 57abf36..ec96343 100644 --- a/polyfill/fetch.mjs +++ b/polyfill/fetch.mjs @@ -4,14 +4,69 @@ import { Lodash as _ } from "./Lodash.mjs"; import { StatusTexts } from "./StatusTexts.mjs"; /** - * fetch + * 统一请求参数。 + * Unified request payload. * - * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API - * @export + * @typedef {object} FetchRequest + * @property {string} url 请求地址 / Request URL. + * @property {string} [method] 请求方法 / HTTP method. + * @property {Record} [headers] 请求头 / Request headers. + * @property {string|ArrayBuffer|ArrayBufferView|object} [body] 请求体 / Request body. + * @property {ArrayBuffer} [bodyBytes] 二进制请求体 / Binary request body. + * @property {number|string} [timeout] 超时(秒或毫秒)/ Timeout (seconds or milliseconds). + * @property {string} [policy] 指定策略 / Preferred policy. + * @property {boolean} [redirection] 是否跟随重定向 / Whether to follow redirects. + * @property {boolean} ["auto-redirect"] 平台重定向字段 / Platform redirect flag. + * @property {Record} [opts] 平台扩展字段 / Platform extension fields. + */ + +/** + * 统一响应结构。 + * Unified response payload. + * + * @typedef {object} FetchResponse + * @property {boolean} ok 请求是否成功 / Whether request is successful. + * @property {number} status 状态码 / HTTP status code. + * @property {number} [statusCode] 状态码别名 / Status code alias. + * @property {string} [statusText] 状态文本 / HTTP status text. + * @property {Record} [headers] 响应头 / Response headers. + * @property {string|ArrayBuffer} [body] 响应体 / Response body. + * @property {ArrayBuffer} [bodyBytes] 二进制响应体 / Binary response body. + */ + +/** + * 跨平台 `fetch` 适配层。 + * Cross-platform `fetch` adapter. + * + * 设计目标: + * Design goal: + * - 仿照 Web API `fetch`(`Window.fetch`)接口设计 + * - Modeled after Web API `fetch` (`Window.fetch`) + * - 统一 VPN App 与 Node.js 环境中的请求调用 + * - Unify request calls across VPN apps and Node.js + * + * 功能: + * Features: + * - 统一 Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Node.js 请求接口 + * - Normalize request APIs across Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Node.js + * - 统一返回体字段(`ok/status/statusText/body/bodyBytes`) + * - Normalize response fields (`ok/status/statusText/body/bodyBytes`) + * + * 与 Web `fetch` 的已知差异: + * Known differences from Web `fetch`: + * - 支持 `policy`、`auto-redirect` 等平台扩展字段 + * - Supports platform extension fields like `policy` and `auto-redirect` + * - 非浏览器平台通过 `$httpClient/$task` 实现,不是原生 Fetch 实现 + * - Non-browser platforms use `$httpClient/$task` instead of native Fetch engine + * - 返回结构包含 `statusCode/bodyBytes` 等兼容字段 + * - Response includes compatibility fields like `statusCode/bodyBytes` + * + * @link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch + * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch * @async - * @param {object|string} resource - * @param {object} [options] - * @returns {Promise} + * @param {FetchRequest|string} resource 请求对象或 URL / Request object or URL string. + * @param {Partial} [options={}] 追加参数 / Extra options. + * @returns {Promise} */ export async function fetch(resource, options = {}) { // 初始化参数 diff --git a/test/Lodash.merge.test.js b/test/Lodash.merge.test.js new file mode 100644 index 0000000..f860582 --- /dev/null +++ b/test/Lodash.merge.test.js @@ -0,0 +1,264 @@ +import { Lodash as _ } from "../polyfill/Lodash.mjs"; +import assert from "node:assert"; +import { describe, it } from "node:test"; + +describe("Lodash.merge", () => { + describe("基础合并", () => { + it("应该合并两个简单对象", () => { + const target = { a: 1, b: 2 }; + const source = { c: 3 }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: 1, b: 2, c: 3 }); + }); + + it("应该用源对象覆盖目标对象的同名属性", () => { + const target = { a: 1, b: 2 }; + const source = { b: 3 }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: 1, b: 3 }); + }); + + it("应该返回修改后的目标对象(mutates target)", () => { + const target = { a: 1 }; + const source = { b: 2 }; + const result = _.merge(target, source); + assert.strictEqual(result, target); + }); + }); + + describe("深度合并", () => { + it("应该递归合并嵌套对象", () => { + const target = { a: { b: 1, c: 2 } }; + const source = { a: { d: 3 } }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: { b: 1, c: 2, d: 3 } }); + }); + + it("应该递归合并多层嵌套对象", () => { + const target = { a: { b: { c: 1 } } }; + const source = { a: { b: { d: 2 }, e: 3 } }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: { b: { c: 1, d: 2 }, e: 3 } }); + }); + + it("嵌套对象中的同名属性应该被覆盖", () => { + const target = { a: { b: 1, c: 2 } }; + const source = { a: { b: 10 } }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: { b: 10, c: 2 } }); + }); + }); + + describe("多个源对象", () => { + it("应该依次合并多个源对象", () => { + const target = { a: 1 }; + const source1 = { b: 2 }; + const source2 = { c: 3 }; + const result = _.merge(target, source1, source2); + assert.deepStrictEqual(result, { a: 1, b: 2, c: 3 }); + }); + + it("后面的源对象应该覆盖前面的", () => { + const target = { a: 1 }; + const source1 = { a: 2, b: 2 }; + const source2 = { a: 3 }; + const result = _.merge(target, source1, source2); + assert.deepStrictEqual(result, { a: 3, b: 2 }); + }); + }); + + describe("数组处理", () => { + it("数组应该被直接覆盖而不是合并", () => { + const target = { a: [1, 2, 3] }; + const source = { a: [4, 5] }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: [4, 5] }); + }); + + it("对象覆盖数组", () => { + const target = { a: [1, 2, 3] }; + const source = { a: { b: 1 } }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: { b: 1 } }); + }); + + it("数组覆盖对象", () => { + const target = { a: { b: 1 } }; + const source = { a: [1, 2, 3] }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: [1, 2, 3] }); + }); + + it("空数组不覆盖已有值", () => { + const target = { a: [1, 2, 3], b: { c: 1 }, d: "hello" }; + const source = { a: [], b: [], d: [] }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: [1, 2, 3], b: { c: 1 }, d: "hello" }); + }); + + it("空数组可以赋值给 undefined 的目标属性", () => { + const target = { a: 1 }; + const source = { b: [] }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: 1, b: [] }); + }); + + it("空数组不覆盖已有的空数组", () => { + const target = { a: [] }; + const source = { a: [] }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: [] }); + assert.strictEqual(result.a, target.a); // 保持原引用 + }); + }); + + describe("Map 处理", () => { + it("应该合并两个 Map", () => { + const target = { a: new Map([["x", 1], ["y", 2]]) }; + const source = { a: new Map([["y", 3], ["z", 4]]) }; + const result = _.merge(target, source); + assert.deepStrictEqual(result.a, new Map([["x", 1], ["y", 3], ["z", 4]])); + }); + + it("空 Map 不覆盖已有 Map", () => { + const targetMap = new Map([["x", 1]]); + const target = { a: targetMap }; + const source = { a: new Map() }; + const result = _.merge(target, source); + assert.strictEqual(result.a, targetMap); + assert.deepStrictEqual(result.a, new Map([["x", 1]])); + }); + + it("空 Map 不覆盖已有非 Map 值", () => { + const target = { a: { b: 1 } }; + const source = { a: new Map() }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: { b: 1 } }); + }); + + it("空 Map 可以赋值给 undefined 的目标属性", () => { + const target = { a: 1 }; + const source = { b: new Map() }; + const result = _.merge(target, source); + assert.ok(result.b instanceof Map); + assert.strictEqual(result.b.size, 0); + }); + + it("非空 Map 覆盖非 Map 值", () => { + const target = { a: { b: 1 } }; + const source = { a: new Map([["x", 1]]) }; + const result = _.merge(target, source); + assert.ok(result.a instanceof Map); + assert.deepStrictEqual(result.a, new Map([["x", 1]])); + }); + }); + + describe("Set 处理", () => { + it("应该合并两个 Set(取并集)", () => { + const target = { a: new Set([1, 2, 3]) }; + const source = { a: new Set([3, 4, 5]) }; + const result = _.merge(target, source); + assert.deepStrictEqual(result.a, new Set([1, 2, 3, 4, 5])); + }); + + it("空 Set 不覆盖已有 Set", () => { + const targetSet = new Set([1, 2]); + const target = { a: targetSet }; + const source = { a: new Set() }; + const result = _.merge(target, source); + assert.strictEqual(result.a, targetSet); + assert.deepStrictEqual(result.a, new Set([1, 2])); + }); + + it("空 Set 不覆盖已有非 Set 值", () => { + const target = { a: [1, 2, 3] }; + const source = { a: new Set() }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: [1, 2, 3] }); + }); + + it("空 Set 可以赋值给 undefined 的目标属性", () => { + const target = { a: 1 }; + const source = { b: new Set() }; + const result = _.merge(target, source); + assert.ok(result.b instanceof Set); + assert.strictEqual(result.b.size, 0); + }); + + it("非空 Set 覆盖非 Set 值", () => { + const target = { a: [1, 2, 3] }; + const source = { a: new Set([4, 5]) }; + const result = _.merge(target, source); + assert.ok(result.a instanceof Set); + assert.deepStrictEqual(result.a, new Set([4, 5])); + }); + }); + + describe("特殊值处理", () => { + it("目标对象为 null 时返回 null", () => { + const result = _.merge(null, { a: 1 }); + assert.strictEqual(result, null); + }); + + it("目标对象为 undefined 时返回 undefined", () => { + const result = _.merge(undefined, { a: 1 }); + assert.strictEqual(result, undefined); + }); + + it("源对象为 null 时跳过", () => { + const target = { a: 1 }; + const result = _.merge(target, null); + assert.deepStrictEqual(result, { a: 1 }); + }); + + it("源对象为 undefined 时跳过", () => { + const target = { a: 1 }; + const result = _.merge(target, undefined); + assert.deepStrictEqual(result, { a: 1 }); + }); + + it("源属性值为 undefined 时不覆盖目标属性", () => { + const target = { a: 1, b: 2 }; + const source = { a: undefined, c: 3 }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: 1, b: 2, c: 3 }); + }); + + it("源属性值为 null 时覆盖目标属性", () => { + const target = { a: 1 }; + const source = { a: null }; + const result = _.merge(target, source); + assert.deepStrictEqual(result, { a: null }); + }); + }); + + describe("配置对象合并场景", () => { + it("应该正确合并典型的配置对象", () => { + const defaultSettings = { + theme: "light", + language: "en", + notifications: { + email: true, + push: true, + sms: false, + }, + }; + const userSettings = { + theme: "dark", + notifications: { + push: false, + }, + }; + const result = _.merge(defaultSettings, userSettings); + assert.deepStrictEqual(result, { + theme: "dark", + language: "en", + notifications: { + email: true, + push: false, + sms: false, + }, + }); + }); + }); +}); diff --git a/test/argument.test.js b/test/argument.test.js new file mode 100644 index 0000000..72d502d --- /dev/null +++ b/test/argument.test.js @@ -0,0 +1,64 @@ +import assert from "node:assert"; +import { afterEach, describe, it } from "node:test"; + +let importSeed = 0; +const argumentModule = new URL("../lib/argument.mjs", import.meta.url); +const packageEntryModule = new URL("../index.js", import.meta.url); +const importWithArgument = async value => { + if (typeof value === "undefined") globalThis.$argument = {}; + else globalThis.$argument = value; + importSeed += 1; + await import(`${argumentModule}?test=${importSeed}`); + return globalThis.$argument; +}; + +describe("argument", () => { + afterEach(() => { + globalThis.$argument = {}; + }); + + it("应该解析字符串参数", async () => { + const result = await importWithArgument("foo=bar&count=1"); + assert.deepStrictEqual(result, { foo: "bar", count: "1" }); + assert.deepStrictEqual(globalThis.$argument, { foo: "bar", count: "1" }); + }); + + it("应该解析点号路径参数", async () => { + const result = await importWithArgument("a.b.c=123&a.d=456"); + assert.deepStrictEqual(result, { a: { b: { c: "123" }, d: "456" } }); + assert.deepStrictEqual(globalThis.$argument, { a: { b: { c: "123" }, d: "456" } }); + }); + + it("应该解析带双引号的参数值", async () => { + const result = await importWithArgument('a.b.c="[1,2,3]"&a.d="456"'); + assert.deepStrictEqual(result, { a: { b: { c: "[1,2,3]" }, d: "456" } }); + assert.deepStrictEqual(globalThis.$argument, { a: { b: { c: "[1,2,3]" }, d: "456" } }); + }); + + it("应该处理对象参数", async () => { + const result = await importWithArgument({ "nested.value": "ok" }); + assert.deepStrictEqual(result, { nested: { value: "ok" } }); + assert.deepStrictEqual(globalThis.$argument, { nested: { value: "ok" } }); + }); + + it("应该处理未定义参数", async () => { + const result = await importWithArgument(); + assert.deepStrictEqual(result, {}); + assert.deepStrictEqual(globalThis.$argument, {}); + }); + + it("应该支持全局 $argument", async () => { + const result = await importWithArgument("mode=on"); + assert.deepStrictEqual(result, { mode: "on" }); + assert.deepStrictEqual(globalThis.$argument, { mode: "on" }); + }); + + it("应该从包入口导出 $argument 快照", async () => { + globalThis.$argument = "a.b=1"; + importSeed += 1; + const mod = await import(`${packageEntryModule}?test=${importSeed}`); + assert.deepStrictEqual(mod.$argument, { a: { b: "1" } }); + assert.deepStrictEqual(mod.argument, { a: { b: "1" } }); + assert.deepStrictEqual(globalThis.$argument, { a: { b: "1" } }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ca246d1..a1ade11 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "include": [ "polyfill/URL.mts", "polyfill/URLSearchParams.mts", + "types/**/*.d.ts", ], "compilerOptions": { // Tells TypeScript to read JS files, as diff --git a/types/nsnanocat-util.d.ts b/types/nsnanocat-util.d.ts new file mode 100644 index 0000000..d878c77 --- /dev/null +++ b/types/nsnanocat-util.d.ts @@ -0,0 +1,143 @@ +declare module "@nsnanocat/util" { + export type AppName = "Quantumult X" | "Loon" | "Shadowrocket" | "Node.js" | "Egern" | "Surge" | "Stash"; + + export const $app: AppName | undefined; + export const $argument: Record; + export const argument: typeof $argument; + + export interface DonePayload { + status?: number | string; + url?: string; + headers?: Record; + body?: string | ArrayBuffer | ArrayBufferView; + bodyBytes?: ArrayBuffer; + policy?: string; + } + + export function done(object?: DonePayload): void; + + export interface NotificationContentObject { + open?: string; + "open-url"?: string; + url?: string; + openUrl?: string; + copy?: string; + "update-pasteboard"?: string; + updatePasteboard?: string; + media?: string; + "media-url"?: string; + mediaUrl?: string; + mime?: string; + "auto-dismiss"?: number; + sound?: string; + } + + export type NotificationContent = NotificationContentObject | string | number | boolean; + + export function notification( + title?: string, + subtitle?: string, + body?: string, + content?: NotificationContent, + ): void; + + export function time(format: string, ts?: number): string; + export function wait(delay?: number): Promise; + + export interface FetchRequest { + url: string; + method?: string; + headers?: Record; + body?: string | ArrayBuffer | ArrayBufferView | object; + bodyBytes?: ArrayBuffer; + timeout?: number | string; + policy?: string; + redirection?: boolean; + "auto-redirect"?: boolean; + opts?: Record; + [key: string]: unknown; + } + + export interface FetchResponse { + ok: boolean; + status: number; + statusCode?: number; + statusText?: string; + headers?: Record; + body?: string | ArrayBuffer; + bodyBytes?: ArrayBuffer; + [key: string]: unknown; + } + + export function fetch(resource: FetchRequest | string, options?: Partial): Promise; + + export class Console { + static clear(): void; + static count(label?: string): void; + static countReset(label?: string): void; + static debug(...msg: unknown[]): void; + static error(...msg: unknown[]): void; + static exception(...msg: unknown[]): void; + static group(label: string): number; + static groupEnd(): string | undefined; + static info(...msg: unknown[]): void; + static get logLevel(): "OFF" | "ERROR" | "WARN" | "INFO" | "DEBUG" | "ALL"; + static set logLevel(level: number | string); + static log(...msg: unknown[]): void; + static time(label?: string): Map; + static timeEnd(label?: string): boolean; + static timeLog(label?: string): void; + static warn(...msg: unknown[]): void; + } + + export class Lodash { + static escape(string: string): string; + static get(object?: Record, path?: string | string[], defaultValue?: D): T | D; + static merge>(object: T, ...sources: Array | null | undefined>): T; + static omit>(object?: T, paths?: string | string[]): T; + static pick, K extends keyof T>(object?: T, paths?: K | K[]): Pick; + static set>(object: T, path: string | string[], value: unknown): T; + static toPath(value: string): string[]; + static unescape(string: string): string; + static unset(object?: Record, path?: string | string[]): boolean; + } + + export const StatusTexts: Record; + + export class Storage { + static data: Record | null; + static dataFile: string; + static getItem(keyName: string, defaultValue?: T): T; + static setItem(keyName: string, keyValue: unknown): boolean; + static removeItem(keyName: string): boolean; + static clear(): boolean; + } +} + +declare module "@nsnanocat/util/getStorage.mjs" { + export interface StorageProfile { + Settings: Record; + Configs: Record; + Caches: Record; + } + + export function traverseObject( + o: Record, + c: (key: string, value: unknown) => unknown, + ): Record; + + export function string2number(string: string): string | number; + + export function value2array(value: string | number | boolean | string[] | null | undefined): Array; + + export default function getStorage( + key: string, + names: string | string[] | Array, + database: Record, + ): StorageProfile; +} + +declare module "@nsnanocat/util/lib/environment.mjs" { + export const $environment: Record; + export function environment(): Record; +}